From 4d3ee472359b8e0150086b90b0f969c9332a3975 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sat, 25 Apr 2026 20:02:10 -0400 Subject: [PATCH] =?UTF-8?q?Auto:=20focas-f2d=20=E2=80=94=20PMC=20range=20c?= =?UTF-8?q?oalescing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #266 --- .../FocasDriver.cs | 174 +++++++++++++ .../FwlibFocasClient.cs | 36 +++ .../IFocasClient.cs | 39 +++ .../Wire/FocasPmcCoalescer.cs | 152 ++++++++++++ .../FakeFocasClient.cs | 27 ++ .../FocasPmcCoalescedReadTests.cs | 232 ++++++++++++++++++ .../FocasPmcCoalescerTests.cs | 176 +++++++++++++ 7 files changed, 836 insertions(+) create mode 100644 src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/Wire/FocasPmcCoalescer.cs create mode 100644 tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasPmcCoalescedReadTests.cs create mode 100644 tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasPmcCoalescerTests.cs diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriver.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriver.cs index 7c0e3d3..2efda87 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriver.cs +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriver.cs @@ -1,4 +1,6 @@ +using System.Buffers.Binary; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; +using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Wire; namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS; @@ -291,6 +293,15 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery, var now = DateTime.UtcNow; var results = new DataValueSnapshot[fullReferences.Count]; + // PR F2-d (issue #266): pre-pass coalesces same-letter / same-path PMC byte reads + // in this batch into one wire call per group via FocasPmcCoalescer. Each entry in + // prefetched maps the original index to the byte slice + status from the range + // read; the per-tag loop below short-circuits to the cached slice when present + // and falls back to per-tag ReadAsync otherwise. Bit-addressed PMC tags + // (R100.3) coalesce on their parent byte (R100); the bit extract still happens + // in the existing decode path. + var prefetched = await PrefetchPmcRangesAsync(fullReferences, cancellationToken).ConfigureAwait(false); + for (var i = 0; i < fullReferences.Count; i++) { var reference = fullReferences[i]; @@ -391,6 +402,26 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery, await client.SetPathAsync(parsed.PathId, cancellationToken).ConfigureAwait(false); device.LastSetPath = parsed.PathId; } + + // Coalesced PMC range fast path (issue #266) — if the prefetch pass already + // satisfied this byte range as part of a coalesced wire call, decode from + // the cached slice instead of issuing another per-tag ReadAsync. + if (prefetched is not null && prefetched.TryGetValue(i, out var slice)) + { + if (slice.Status != FocasStatusMapper.Good) + { + results[i] = new DataValueSnapshot(null, slice.Status, null, now); + if (slice.Status != FocasStatusMapper.Good) + _health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, + $"FOCAS status 0x{slice.Status:X8} reading {reference}"); + continue; + } + var decoded = DecodePmcSlice(slice.Bytes, def.DataType, parsed.BitIndex); + results[i] = new DataValueSnapshot(decoded, FocasStatusMapper.Good, now, now); + _health = new DriverHealth(DriverState.Healthy, now, null); + continue; + } + var (value, status) = parsed.Kind == FocasAreaKind.Diagnostic ? await client.ReadDiagnosticAsync( parsed.Number, parsed.BitIndex ?? 0, def.DataType, cancellationToken).ConfigureAwait(false) @@ -1054,6 +1085,149 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery, return new DataValueSnapshot(value, FocasStatusMapper.Good, now, now); } + /// + /// Pre-pass for that groups same-letter / same-path PMC byte + /// reads from into coalesced range reads (issue + /// #266). Returns a map from the original batch index to the byte slice + status the + /// wire returned for that tag's bytes; entries are absent for non-PMC tags + for tags + /// whose coalesced read failed at the connect / set-path / parse step (those fall + /// back to the existing per-tag dispatch in the main loop). Bit-addressed PMC tags + /// (e.g. R100.3) are coalesced on their parent byte; the bit extract is + /// deferred to so the slice still satisfies them. + /// + private async Task?> PrefetchPmcRangesAsync( + IReadOnlyList fullReferences, CancellationToken cancellationToken) + { + // Build per-device PMC request lists. Skip non-PMC tags + tags that don't resolve. + Dictionary>? perDevice = null; + for (var i = 0; i < fullReferences.Count; i++) + { + var reference = fullReferences[i]; + if (_statusNodesByName.ContainsKey(reference) || + _productionNodesByName.ContainsKey(reference) || + _modalNodesByName.ContainsKey(reference) || + _overrideNodesByName.ContainsKey(reference) || + _toolingNodesByName.ContainsKey(reference) || + _offsetNodesByName.ContainsKey(reference) || + _messagesNodesByName.ContainsKey(reference) || + _currentBlockNodesByName.ContainsKey(reference) || + _diagnosticsNodesByName.ContainsKey(reference)) + continue; + if (!_tagsByName.TryGetValue(reference, out var def)) continue; + if (!_devices.TryGetValue(def.DeviceHostAddress, out _)) continue; + var parsed = FocasAddress.TryParse(def.Address); + if (parsed is null) continue; + if (parsed.Kind != FocasAreaKind.Pmc) continue; + if (string.IsNullOrEmpty(parsed.PmcLetter)) continue; + perDevice ??= new(StringComparer.OrdinalIgnoreCase); + if (!perDevice.TryGetValue(def.DeviceHostAddress, out var list)) + perDevice[def.DeviceHostAddress] = list = new(); + list.Add((i, parsed, def)); + } + if (perDevice is null) return null; + + var result = new Dictionary(); + foreach (var (host, list) in perDevice) + { + // Single-tag PMC reads aren't worth coalescing — fall through to the per-tag + // path so we don't pay the connect/set-path overhead twice. + if (list.Count < 2) continue; + var device = _devices[host]; + + IFocasClient client; + try + { + client = await EnsureConnectedAsync(device, cancellationToken).ConfigureAwait(false); + } + catch (OperationCanceledException) { throw; } + catch + { + continue; // per-tag path will surface BadCommunicationError per tag + } + + // Build coalescer requests. Skip path-out-of-range tags here so the per-tag + // path can map them to BadOutOfRange consistently. + var pmcRequests = new List(list.Count); + foreach (var (idx, parsed, def) in list) + { + if (parsed.PathId > 1 && device.PathCount > 0 && parsed.PathId > device.PathCount) continue; + pmcRequests.Add(new PmcAddressRequest( + parsed.PmcLetter!, parsed.PathId, + parsed.Number, FocasPmcCoalescer.ByteWidth(def.DataType), idx)); + } + if (pmcRequests.Count < 2) continue; + + var groups = FocasPmcCoalescer.Plan(pmcRequests); + foreach (var group in groups) + { + if (group.Members.Count < 2) continue; // single-member group — no benefit + if (group.PathId != 1 && device.LastSetPath != group.PathId) + { + try + { + await client.SetPathAsync(group.PathId, cancellationToken).ConfigureAwait(false); + device.LastSetPath = group.PathId; + } + catch (OperationCanceledException) { throw; } + catch { continue; } + } + + byte[]? buffer; uint status; + try + { + (buffer, status) = await client.ReadPmcRangeAsync( + group.Letter, group.PathId, group.StartByte, group.ByteCount, cancellationToken) + .ConfigureAwait(false); + } + catch (OperationCanceledException) { throw; } + catch + { + continue; // per-tag path will surface BadCommunicationError + } + + foreach (var member in group.Members) + { + if (status != FocasStatusMapper.Good || buffer is null) + { + result[member.OriginalIndex] = new PmcSlice(Array.Empty(), status); + continue; + } + var slice = new byte[member.ByteWidth]; + if (member.Offset + member.ByteWidth <= buffer.Length) + Array.Copy(buffer, member.Offset, slice, 0, member.ByteWidth); + result[member.OriginalIndex] = new PmcSlice(slice, FocasStatusMapper.Good); + } + } + } + return result.Count == 0 ? null : result; + } + + /// + /// Decode the bytes of a coalesced PMC slice into the boxed managed value the + /// driver returns from . Mirrors the per-byte decode in + /// 's ReadPmc + the bit-extract path so the + /// coalesced read is observably equivalent to a per-tag read. + /// + private static object? DecodePmcSlice(byte[] slice, FocasDataType type, int? bitIndex) => type switch + { + FocasDataType.Bit when bitIndex is int bi => + slice.Length > 0 ? (object)((slice[0] & (1 << bi)) != 0) : null, + FocasDataType.Byte => + slice.Length > 0 ? (object)(sbyte)slice[0] : null, + FocasDataType.Int16 => + slice.Length >= 2 ? (object)BinaryPrimitives.ReadInt16LittleEndian(slice) : null, + FocasDataType.Int32 => + slice.Length >= 4 ? (object)BinaryPrimitives.ReadInt32LittleEndian(slice) : null, + FocasDataType.Float32 => + slice.Length >= 4 ? (object)BinaryPrimitives.ReadSingleLittleEndian(slice) : null, + FocasDataType.Float64 => + slice.Length >= 8 ? (object)BinaryPrimitives.ReadDoubleLittleEndian(slice) : null, + _ => slice.Length > 0 ? (object)slice[0] : null, + }; + + /// One coalesced PMC slice — bytes the wire returned for one batch member + the status. + private readonly record struct PmcSlice(byte[] Bytes, uint Status); + /// /// Apply cnc_getfigure-derived decimal scaling to a raw position value. /// Returns divided by 10^decimalPlaces when the diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FwlibFocasClient.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FwlibFocasClient.cs index 9ff8ae2..5a99cad 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FwlibFocasClient.cs +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FwlibFocasClient.cs @@ -445,6 +445,42 @@ internal sealed class FwlibFocasClient : IFocasClient return (value, FocasStatusMapper.Good); } + /// + /// Range read for the PMC coalescer (issue #266). FWLIB's pmc_rdpmcrng + /// payload is capped at 40 bytes (the IODBPMC.Data union width), so requested + /// ranges larger than that are chunked into 32-byte sub-calls internally — + /// callers still see one logical range, which matches the + /// 's "one wire call per group" semantics. + /// + public Task<(byte[]? buffer, uint status)> ReadPmcRangeAsync( + string letter, int pathId, int startByte, int byteCount, CancellationToken cancellationToken) + { + if (!_connected) return Task.FromResult<(byte[]?, uint)>((null, FocasStatusMapper.BadCommunicationError)); + cancellationToken.ThrowIfCancellationRequested(); + if (byteCount <= 0) return Task.FromResult<(byte[]?, uint)>((Array.Empty(), FocasStatusMapper.Good)); + + var addrType = FocasPmcAddrType.FromLetter(letter) + ?? throw new InvalidOperationException($"Unknown PMC letter '{letter}'."); + var result = new byte[byteCount]; + const int chunkBytes = 32; + var offset = 0; + while (offset < byteCount) + { + cancellationToken.ThrowIfCancellationRequested(); + var thisChunk = Math.Min(chunkBytes, byteCount - offset); + var buf = new FwlibNative.IODBPMC { Data = new byte[40] }; + var ret = FwlibNative.PmcRdPmcRng( + _handle, addrType, FocasPmcDataType.Byte, + (ushort)(startByte + offset), + (ushort)(startByte + offset + thisChunk - 1), + (ushort)(8 + thisChunk), ref buf); + if (ret != 0) return Task.FromResult<(byte[]?, uint)>((null, FocasStatusMapper.MapFocasReturn(ret))); + Array.Copy(buf.Data, 0, result, offset, thisChunk); + offset += thisChunk; + } + return Task.FromResult<(byte[]?, uint)>((result, FocasStatusMapper.Good)); + } + private uint WritePmc(FocasAddress address, FocasDataType type, object? value) { var addrType = FocasPmcAddrType.FromLetter(address.PmcLetter ?? "") ?? (short)0; diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/IFocasClient.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/IFocasClient.cs index 14e09b4..7e5d986 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/IFocasClient.cs +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/IFocasClient.cs @@ -184,6 +184,45 @@ public interface IFocasClient : IDisposable /// Task SetPathAsync(int pathId, CancellationToken cancellationToken) => Task.CompletedTask; + + /// + /// Read a contiguous range of PMC bytes in a single wire call (FOCAS + /// pmc_rdpmcrng with byte data type) for the given + /// (R, D, X, etc.) starting at and + /// spanning bytes. Returned tuple has the byte buffer + /// (length on success) + the OPC UA status mapped through + /// . Used by to coalesce + /// same-letter/same-path PMC reads in a batch into one round trip per range + /// (issue #266 — see ). + /// + /// Default falls back to per-byte + /// calls so transport variants that haven't extended their wire surface still work + /// correctly — they just won't see the round-trip reduction. The fallback short-circuits + /// on the first non-Good status so a partial buffer isn't returned with a Good code. + /// + /// + async Task<(byte[]? buffer, uint status)> ReadPmcRangeAsync( + string letter, int pathId, int startByte, int byteCount, CancellationToken cancellationToken) + { + if (byteCount <= 0) return (Array.Empty(), FocasStatusMapper.Good); + var buf = new byte[byteCount]; + for (var i = 0; i < byteCount; i++) + { + cancellationToken.ThrowIfCancellationRequested(); + var addr = new FocasAddress(FocasAreaKind.Pmc, letter, startByte + i, BitIndex: null, PathId: pathId); + var (value, status) = await ReadAsync(addr, FocasDataType.Byte, cancellationToken).ConfigureAwait(false); + if (status != FocasStatusMapper.Good) return (null, status); + buf[i] = value switch + { + sbyte s => unchecked((byte)s), + byte b => b, + int n => unchecked((byte)n), + short s => unchecked((byte)s), + _ => 0, + }; + } + return (buf, FocasStatusMapper.Good); + } } /// diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/Wire/FocasPmcCoalescer.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/Wire/FocasPmcCoalescer.cs new file mode 100644 index 0000000..905920b --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/Wire/FocasPmcCoalescer.cs @@ -0,0 +1,152 @@ +namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Wire; + +/// +/// One PMC byte-level read request a caller wants to satisfy. is +/// how many consecutive PMC bytes the caller's tag occupies (e.g. Bit/Byte = 1, Int16 = 2, +/// Int32/Float32 = 4, Float64 = 8). is the caller's row index +/// in the batch — the coalescer carries it through to the planned group so the driver can +/// fan-out the slice back to the original snapshot slot. +/// +/// +/// Bit-addressed PMC tags (e.g. R100.3) supply their parent byte (100) + +/// ByteWidth = 1; the slice-then-mask happens in the existing decode path, so +/// the coalescer doesn't need to know about the bit index. +/// +public sealed record PmcAddressRequest( + string Letter, + int PathId, + int ByteNumber, + int ByteWidth, + int OriginalIndex); + +/// +/// One member of a coalesced PMC range — the original index + the byte offset within +/// the planned range buffer where the member's bytes start. The caller slices +/// buffer[Offset .. Offset + ByteWidth] to recover the per-tag wire-shape bytes. +/// +public sealed record PmcRangeMember(int OriginalIndex, int Offset, int ByteWidth); + +/// +/// One coalesced PMC range — a single FOCAS pmc_rdpmcrng wire call satisfies +/// every member. is bounded by . +/// +public sealed record PmcRangeGroup( + string Letter, + int PathId, + int StartByte, + int ByteCount, + IReadOnlyList Members); + +/// +/// Plans one or more coalesced PMC range reads from a flat batch of per-tag requests. +/// Same-letter / same-path requests whose byte ranges overlap or whose gap is no larger +/// than are merged into a single wire call up to a +/// cap (issue #266). +/// +/// +/// The cap matches the conservative ceiling Fanuc spec lists for +/// pmc_rdpmcrng — most controllers accept larger ranges but 256 is the lowest +/// common denominator across 0i / 16i / 30i firmware. Splitting on the cap is fine — +/// each partition still saves N-1 round trips relative to per-byte reads. +/// +/// The = 16 mirrors the Modbus coalescer's bridge +/// policy: small gaps are cheaper to over-read (one extra wire call vs. several short +/// ones) but unbounded bridging would pull large unused regions over the wire on sparse +/// PMC layouts. +/// +public static class FocasPmcCoalescer +{ + /// Maximum bytes per coalesced range — conservative ceiling for older Fanuc firmware. + public const int MaxRangeBytes = 256; + + /// Maximum gap (in bytes) bridged between consecutive sub-requests within a group. + public const int MaxBridgeGap = 16; + + /// + /// Plan range reads from . Group key is + /// (Letter, PathId). Within a group, requests are sorted by start byte then + /// greedily packed into ranges that respect + + /// . + /// + public static IReadOnlyList Plan(IEnumerable addresses) + { + ArgumentNullException.ThrowIfNull(addresses); + + var groups = new List(); + var byKey = addresses + .Where(a => !string.IsNullOrEmpty(a.Letter) && a.ByteWidth > 0 && a.ByteNumber >= 0) + .GroupBy(a => (Letter: a.Letter.ToUpperInvariant(), a.PathId)); + + foreach (var key in byKey) + { + var sorted = key.OrderBy(a => a.ByteNumber).ThenBy(a => a.OriginalIndex).ToList(); + var pending = new List(); + var rangeStart = -1; + var rangeEnd = -1; + + void Flush() + { + if (pending.Count == 0) return; + var members = pending.Select(p => + new PmcRangeMember(p.OriginalIndex, p.ByteNumber - rangeStart, p.ByteWidth)).ToList(); + groups.Add(new PmcRangeGroup(key.Key.Letter, key.Key.PathId, rangeStart, + rangeEnd - rangeStart + 1, members)); + pending.Clear(); + rangeStart = -1; + rangeEnd = -1; + } + + foreach (var req in sorted) + { + var reqStart = req.ByteNumber; + var reqEnd = req.ByteNumber + req.ByteWidth - 1; + if (pending.Count == 0) + { + rangeStart = reqStart; + rangeEnd = reqEnd; + pending.Add(req); + continue; + } + + // Bridge if the gap between the existing range end + this request's start is + // within the bridge cap. Overlapping or contiguous ranges always bridge + // (gap <= 0). The cap is enforced on the projected union: extending the range + // must not exceed MaxRangeBytes from rangeStart. + var gap = reqStart - rangeEnd - 1; + var projectedEnd = Math.Max(rangeEnd, reqEnd); + var projectedSize = projectedEnd - rangeStart + 1; + if (gap <= MaxBridgeGap && projectedSize <= MaxRangeBytes) + { + rangeEnd = projectedEnd; + pending.Add(req); + } + else + { + Flush(); + rangeStart = reqStart; + rangeEnd = reqEnd; + pending.Add(req); + } + } + + Flush(); + } + + return groups; + } + + /// + /// The number of consecutive PMC bytes a tag of occupies on + /// the wire. Used by the driver to populate + /// before planning. Bit-addressed tags supply 1 here — the bit-extract happens + /// in the decode path after the slice. + /// + public static int ByteWidth(FocasDataType type) => type switch + { + FocasDataType.Bit or FocasDataType.Byte => 1, + FocasDataType.Int16 => 2, + FocasDataType.Int32 or FocasDataType.Float32 => 4, + FocasDataType.Float64 => 8, + _ => 1, + }; +} diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FakeFocasClient.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FakeFocasClient.cs index c332bd4..07fa8e5 100644 --- a/tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FakeFocasClient.cs +++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FakeFocasClient.cs @@ -80,6 +80,33 @@ internal class FakeFocasClient : IFocasClient return Task.CompletedTask; } + /// + /// Per-letter / per-path byte storage the coalesced range path reads from. Tests + /// populate PmcByteRanges[("R", 1)] = new byte[size] + the corresponding values to + /// drive both the per-tag + the coalesced + /// path against the same source of truth (issue #266). + /// + public Dictionary<(string Letter, int PathId), byte[]> PmcByteRanges { get; } = new(); + + /// + /// Ordered log of pmc_rdpmcrng-shaped range calls observed on this fake + /// session — one entry per coalesced wire call. Tests assert this count to verify + /// coalescing actually collapsed N per-byte reads into one range read (issue #266). + /// + public List<(string Letter, int PathId, int StartByte, int ByteCount)> RangeReadLog { get; } = new(); + + public virtual Task<(byte[]? buffer, uint status)> ReadPmcRangeAsync( + string letter, int pathId, int startByte, int byteCount, CancellationToken ct) + { + RangeReadLog.Add((letter, pathId, startByte, byteCount)); + if (!PmcByteRanges.TryGetValue((letter.ToUpperInvariant(), pathId), out var src)) + return Task.FromResult<(byte[]?, uint)>((new byte[byteCount], FocasStatusMapper.Good)); + var buf = new byte[byteCount]; + var copy = Math.Min(byteCount, Math.Max(0, src.Length - startByte)); + if (copy > 0) Array.Copy(src, startByte, buf, 0, copy); + return Task.FromResult<(byte[]?, uint)>((buf, FocasStatusMapper.Good)); + } + public virtual void Dispose() { DisposeCount++; diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasPmcCoalescedReadTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasPmcCoalescedReadTests.cs new file mode 100644 index 0000000..cdc5482 --- /dev/null +++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasPmcCoalescedReadTests.cs @@ -0,0 +1,232 @@ +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; +using ZB.MOM.WW.OtOpcUa.Driver.FOCAS; + +namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests; + +[Trait("Category", "Unit")] +public sealed class FocasPmcCoalescedReadTests +{ + private const string Host = "focas://10.0.0.5:8193"; + + private static (FocasDriver drv, FakeFocasClientFactory factory, FakeFocasClient client) NewDriver( + params FocasTagDefinition[] tags) + { + var client = new FakeFocasClient(); + var factory = new FakeFocasClientFactory { Customise = () => client }; + var drv = new FocasDriver(new FocasDriverOptions + { + Devices = [new FocasDeviceOptions(Host)], + Tags = tags, + Probe = new FocasProbeOptions { Enabled = false }, + }, "drv-coalesce", factory); + return (drv, factory, client); + } + + private static FocasTagDefinition Tag(string name, string addr, FocasDataType type) => + new(name, Host, addr, type); + + [Fact] + public async Task Hundred_contiguous_PMC_bytes_collapse_to_one_wire_call() + { + // 100 contiguous R-letter byte-shaped tags → coalescer cap is 256 → one range read. + var tags = new FocasTagDefinition[100]; + var refs = new string[100]; + for (var i = 0; i < 100; i++) + { + tags[i] = Tag($"r{i}", $"R{i}", FocasDataType.Byte); + refs[i] = $"r{i}"; + } + var (drv, _, client) = NewDriver(tags); + client.PmcByteRanges[("R", 1)] = new byte[200]; + for (var i = 0; i < 100; i++) client.PmcByteRanges[("R", 1)][i] = (byte)(i + 1); + await drv.InitializeAsync("{}", CancellationToken.None); + + var snapshots = await drv.ReadAsync(refs, CancellationToken.None); + + snapshots.Count.ShouldBe(100); + foreach (var s in snapshots) s.StatusCode.ShouldBe(FocasStatusMapper.Good); + // sbyte cast: 1 → 1, 100 stays positive + snapshots[0].Value.ShouldBe((sbyte)1); + snapshots[99].Value.ShouldBe((sbyte)100); + client.RangeReadLog.Count.ShouldBe(1); + client.RangeReadLog[0].Letter.ShouldBe("R"); + client.RangeReadLog[0].StartByte.ShouldBe(0); + client.RangeReadLog[0].ByteCount.ShouldBe(100); + } + + [Fact] + public async Task Gap_larger_than_bridge_threshold_splits_into_two_wire_calls() + { + // R0, R1, then R100, R101 — gap of 98 > bridge cap of 16 → 2 ranges. + var tags = new[] + { + Tag("r0", "R0", FocasDataType.Byte), + Tag("r1", "R1", FocasDataType.Byte), + Tag("r100", "R100", FocasDataType.Byte), + Tag("r101", "R101", FocasDataType.Byte), + }; + var (drv, _, client) = NewDriver(tags); + client.PmcByteRanges[("R", 1)] = new byte[200]; + client.PmcByteRanges[("R", 1)][0] = 10; + client.PmcByteRanges[("R", 1)][1] = 11; + client.PmcByteRanges[("R", 1)][100] = 20; + client.PmcByteRanges[("R", 1)][101] = 21; + await drv.InitializeAsync("{}", CancellationToken.None); + + var snapshots = await drv.ReadAsync(["r0", "r1", "r100", "r101"], CancellationToken.None); + + snapshots.Count.ShouldBe(4); + foreach (var s in snapshots) s.StatusCode.ShouldBe(FocasStatusMapper.Good); + snapshots[0].Value.ShouldBe((sbyte)10); + snapshots[3].Value.ShouldBe((sbyte)21); + client.RangeReadLog.Count.ShouldBe(2); + } + + [Fact] + public async Task Different_letters_yield_separate_wire_calls() + { + var tags = new[] + { + Tag("r0", "R0", FocasDataType.Byte), + Tag("r1", "R1", FocasDataType.Byte), + Tag("d0", "D0", FocasDataType.Byte), + Tag("d1", "D1", FocasDataType.Byte), + }; + var (drv, _, client) = NewDriver(tags); + client.PmcByteRanges[("R", 1)] = new byte[8] { 1, 2, 3, 4, 5, 6, 7, 8 }; + client.PmcByteRanges[("D", 1)] = new byte[8] { 9, 10, 11, 12, 13, 14, 15, 16 }; + await drv.InitializeAsync("{}", CancellationToken.None); + + var snapshots = await drv.ReadAsync(["r0", "r1", "d0", "d1"], CancellationToken.None); + + foreach (var s in snapshots) s.StatusCode.ShouldBe(FocasStatusMapper.Good); + snapshots[0].Value.ShouldBe((sbyte)1); + snapshots[2].Value.ShouldBe((sbyte)9); + client.RangeReadLog.Count.ShouldBe(2); + client.RangeReadLog.ShouldContain(c => c.Letter == "R"); + client.RangeReadLog.ShouldContain(c => c.Letter == "D"); + } + + [Fact] + public async Task Different_paths_yield_separate_wire_calls() + { + var tags = new[] + { + Tag("p1a", "R0", FocasDataType.Byte), + Tag("p1b", "R1", FocasDataType.Byte), + Tag("p2a", "R0@2", FocasDataType.Byte), + Tag("p2b", "R1@2", FocasDataType.Byte), + }; + var (drv, _, client) = NewDriver(tags); + client.PathCount = 2; + client.PmcByteRanges[("R", 1)] = new byte[] { 1, 2 }; + client.PmcByteRanges[("R", 2)] = new byte[] { 7, 8 }; + await drv.InitializeAsync("{}", CancellationToken.None); + + var snapshots = await drv.ReadAsync(["p1a", "p1b", "p2a", "p2b"], CancellationToken.None); + + foreach (var s in snapshots) s.StatusCode.ShouldBe(FocasStatusMapper.Good); + snapshots[0].Value.ShouldBe((sbyte)1); + snapshots[2].Value.ShouldBe((sbyte)7); + client.RangeReadLog.Count.ShouldBe(2); + client.RangeReadLog.ShouldContain(c => c.PathId == 1); + client.RangeReadLog.ShouldContain(c => c.PathId == 2); + } + + [Fact] + public async Task Single_PMC_tag_does_not_invoke_range_path() + { + // Single-tag PMC reads aren't worth coalescing — the driver falls through to the + // existing per-tag dispatch so connect/set-path overhead isn't paid twice. + var (drv, _, client) = NewDriver(Tag("only", "R5", FocasDataType.Byte)); + client.Values["R5"] = (sbyte)42; + await drv.InitializeAsync("{}", CancellationToken.None); + + var snapshots = await drv.ReadAsync(["only"], CancellationToken.None); + + snapshots.Single().Value.ShouldBe((sbyte)42); + client.RangeReadLog.ShouldBeEmpty(); + } + + [Fact] + public async Task Bit_addressed_tags_share_parent_byte_range() + { + // R10.0 + R10.3 + R10.7 + R11.0 — all addressing R10 / R11, one coalesced range. + var tags = new[] + { + Tag("b0", "R10.0", FocasDataType.Bit), + Tag("b3", "R10.3", FocasDataType.Bit), + Tag("b7", "R10.7", FocasDataType.Bit), + Tag("c0", "R11.0", FocasDataType.Bit), + }; + var (drv, _, client) = NewDriver(tags); + client.PmcByteRanges[("R", 1)] = new byte[20]; + // R10 = 0b1000_1001 → bit0=1, bit3=1, bit7=1 + client.PmcByteRanges[("R", 1)][10] = 0b1000_1001; + // R11 = 0b0000_0000 → bit0=0 + client.PmcByteRanges[("R", 1)][11] = 0; + await drv.InitializeAsync("{}", CancellationToken.None); + + var snapshots = await drv.ReadAsync(["b0", "b3", "b7", "c0"], CancellationToken.None); + + snapshots[0].Value.ShouldBe(true); + snapshots[1].Value.ShouldBe(true); + snapshots[2].Value.ShouldBe(true); + snapshots[3].Value.ShouldBe(false); + client.RangeReadLog.Count.ShouldBe(1); + client.RangeReadLog[0].StartByte.ShouldBe(10); + client.RangeReadLog[0].ByteCount.ShouldBe(2); + } + + [Fact] + public async Task Wider_types_decode_correctly_from_coalesced_buffer() + { + // R0 Int16 + R2 Int32 + R6 Byte — contiguous (R0..R6 = 7 bytes), one range. + var tags = new[] + { + Tag("w16", "R0", FocasDataType.Int16), + Tag("w32", "R2", FocasDataType.Int32), + Tag("b", "R6", FocasDataType.Byte), + }; + var (drv, _, client) = NewDriver(tags); + // Little-endian: R0..R1 = 0x1234 → bytes 0x34, 0x12; R2..R5 = 0x12345678 → 0x78,0x56,0x34,0x12; R6=0x05 + client.PmcByteRanges[("R", 1)] = new byte[] { 0x34, 0x12, 0x78, 0x56, 0x34, 0x12, 0x05 }; + await drv.InitializeAsync("{}", CancellationToken.None); + + var snapshots = await drv.ReadAsync(["w16", "w32", "b"], CancellationToken.None); + + snapshots[0].Value.ShouldBe((short)0x1234); + snapshots[1].Value.ShouldBe(0x12345678); + snapshots[2].Value.ShouldBe((sbyte)0x05); + client.RangeReadLog.Count.ShouldBe(1); + client.RangeReadLog[0].ByteCount.ShouldBe(7); + } + + [Fact] + public async Task Non_PMC_tags_in_same_batch_use_per_tag_path() + { + // Mix PMC + Parameter + Macro — only the PMC half coalesces. + var tags = new[] + { + Tag("r0", "R0", FocasDataType.Byte), + Tag("r1", "R1", FocasDataType.Byte), + Tag("p", "PARAM:1820", FocasDataType.Int32), + Tag("m", "MACRO:500", FocasDataType.Float64), + }; + var (drv, _, client) = NewDriver(tags); + client.PmcByteRanges[("R", 1)] = new byte[] { 11, 22 }; + client.Values["PARAM:1820"] = 7777; + client.Values["MACRO:500"] = 2.71828; + await drv.InitializeAsync("{}", CancellationToken.None); + + var snapshots = await drv.ReadAsync(["r0", "r1", "p", "m"], CancellationToken.None); + + snapshots[0].Value.ShouldBe((sbyte)11); + snapshots[1].Value.ShouldBe((sbyte)22); + snapshots[2].Value.ShouldBe(7777); + snapshots[3].Value.ShouldBe(2.71828); + client.RangeReadLog.Count.ShouldBe(1); + } +} diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasPmcCoalescerTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasPmcCoalescerTests.cs new file mode 100644 index 0000000..b622be3 --- /dev/null +++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasPmcCoalescerTests.cs @@ -0,0 +1,176 @@ +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Driver.FOCAS; +using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Wire; + +namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests; + +[Trait("Category", "Unit")] +public sealed class FocasPmcCoalescerTests +{ + [Fact] + public void Empty_input_yields_no_groups() + { + var groups = FocasPmcCoalescer.Plan(Array.Empty()); + groups.ShouldBeEmpty(); + } + + [Fact] + public void Contiguous_same_letter_same_path_coalesces_into_one_group() + { + // 100 contiguous R-letter byte reads at byte 0..99 + var requests = new List(); + for (var i = 0; i < 100; i++) + requests.Add(new PmcAddressRequest("R", PathId: 1, ByteNumber: i, ByteWidth: 1, OriginalIndex: i)); + + var groups = FocasPmcCoalescer.Plan(requests); + + groups.Count.ShouldBe(1); + var g = groups[0]; + g.Letter.ShouldBe("R"); + g.PathId.ShouldBe(1); + g.StartByte.ShouldBe(0); + g.ByteCount.ShouldBe(100); + g.Members.Count.ShouldBe(100); + g.Members[42].Offset.ShouldBe(42); + g.Members[42].OriginalIndex.ShouldBe(42); + } + + [Fact] + public void Range_cap_splits_oversized_runs_into_multiple_groups() + { + // 300 contiguous bytes — must split (cap = 256) + var requests = new List(); + for (var i = 0; i < 300; i++) + requests.Add(new PmcAddressRequest("R", 1, i, 1, i)); + + var groups = FocasPmcCoalescer.Plan(requests); + + groups.Count.ShouldBe(2); + groups[0].ByteCount.ShouldBe(FocasPmcCoalescer.MaxRangeBytes); + groups[0].StartByte.ShouldBe(0); + groups[1].StartByte.ShouldBe(FocasPmcCoalescer.MaxRangeBytes); + groups[1].ByteCount.ShouldBe(300 - FocasPmcCoalescer.MaxRangeBytes); + } + + [Fact] + public void Gap_within_bridge_threshold_is_bridged() + { + // Two runs: R0..R9 then R20..R29 — gap = 10 bytes, within bridge cap of 16. + var requests = new List + { + new("R", 1, 0, 1, 0), + new("R", 1, 9, 1, 1), + new("R", 1, 20, 1, 2), + new("R", 1, 29, 1, 3), + }; + + var groups = FocasPmcCoalescer.Plan(requests); + + groups.Count.ShouldBe(1); + groups[0].StartByte.ShouldBe(0); + groups[0].ByteCount.ShouldBe(30); + } + + [Fact] + public void Gap_larger_than_bridge_threshold_splits() + { + // Two runs: R0 then R100 — gap of 99 bytes >> 16, must split. + var requests = new List + { + new("R", 1, 0, 1, 0), + new("R", 1, 100, 1, 1), + }; + + var groups = FocasPmcCoalescer.Plan(requests); + + groups.Count.ShouldBe(2); + groups[0].StartByte.ShouldBe(0); + groups[1].StartByte.ShouldBe(100); + } + + [Fact] + public void Different_letters_split_into_separate_groups() + { + var requests = new List + { + new("R", 1, 0, 1, 0), + new("R", 1, 1, 1, 1), + new("D", 1, 0, 1, 2), + new("D", 1, 1, 1, 3), + }; + + var groups = FocasPmcCoalescer.Plan(requests); + + groups.Count.ShouldBe(2); + groups.ShouldContain(g => g.Letter == "R" && g.ByteCount == 2); + groups.ShouldContain(g => g.Letter == "D" && g.ByteCount == 2); + } + + [Fact] + public void Different_paths_split_into_separate_groups() + { + var requests = new List + { + new("R", 1, 0, 1, 0), + new("R", 1, 1, 1, 1), + new("R", 2, 0, 1, 2), + new("R", 2, 1, 1, 3), + }; + + var groups = FocasPmcCoalescer.Plan(requests); + + groups.Count.ShouldBe(2); + groups.ShouldContain(g => g.Letter == "R" && g.PathId == 1); + groups.ShouldContain(g => g.Letter == "R" && g.PathId == 2); + } + + [Fact] + public void Wider_data_types_extend_range_correctly() + { + // R0 is Int32 (4 bytes covers R0..R3), R4 is Byte → contiguous, one group of 5 bytes. + var requests = new List + { + new("R", 1, 0, ByteWidth: 4, 0), + new("R", 1, 4, ByteWidth: 1, 1), + }; + + var groups = FocasPmcCoalescer.Plan(requests); + + groups.Count.ShouldBe(1); + groups[0].ByteCount.ShouldBe(5); + groups[0].Members[0].ByteWidth.ShouldBe(4); + groups[0].Members[0].Offset.ShouldBe(0); + groups[0].Members[1].ByteWidth.ShouldBe(1); + groups[0].Members[1].Offset.ShouldBe(4); + } + + [Fact] + public void Overlapping_requests_do_not_grow_range_beyond_their_union() + { + // R10 Int32 (R10..R13) + R12 Byte — overlap; range should still be 4 bytes from 10. + var requests = new List + { + new("R", 1, 10, 4, 0), + new("R", 1, 12, 1, 1), + }; + + var groups = FocasPmcCoalescer.Plan(requests); + + groups.Count.ShouldBe(1); + groups[0].StartByte.ShouldBe(10); + groups[0].ByteCount.ShouldBe(4); + groups[0].Members[1].Offset.ShouldBe(2); // member at byte 12, offset within range = 2 + } + + [Fact] + public void ByteWidth_helper_matches_data_type_sizes() + { + FocasPmcCoalescer.ByteWidth(FocasDataType.Bit).ShouldBe(1); + FocasPmcCoalescer.ByteWidth(FocasDataType.Byte).ShouldBe(1); + FocasPmcCoalescer.ByteWidth(FocasDataType.Int16).ShouldBe(2); + FocasPmcCoalescer.ByteWidth(FocasDataType.Int32).ShouldBe(4); + FocasPmcCoalescer.ByteWidth(FocasDataType.Float32).ShouldBe(4); + FocasPmcCoalescer.ByteWidth(FocasDataType.Float64).ShouldBe(8); + } +}