Auto: focas-f2d — PMC range coalescing

Closes #266
This commit is contained in:
Joseph Doherty
2026-04-25 20:02:10 -04:00
parent 9ebe5bd523
commit 4d3ee47235
7 changed files with 836 additions and 0 deletions

View File

@@ -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);
}
/// <summary>
/// Pre-pass for <see cref="ReadAsync"/> that groups same-letter / same-path PMC byte
/// reads from <paramref name="fullReferences"/> 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. <c>R100.3</c>) are coalesced on their parent byte; the bit extract is
/// deferred to <see cref="DecodePmcSlice"/> so the slice still satisfies them.
/// </summary>
private async Task<Dictionary<int, PmcSlice>?> PrefetchPmcRangesAsync(
IReadOnlyList<string> fullReferences, CancellationToken cancellationToken)
{
// Build per-device PMC request lists. Skip non-PMC tags + tags that don't resolve.
Dictionary<string, List<(int Index, FocasAddress Parsed, FocasTagDefinition Def)>>? 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<int, PmcSlice>();
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<PmcAddressRequest>(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<byte>(), 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;
}
/// <summary>
/// Decode the bytes of a coalesced PMC slice into the boxed managed value the
/// driver returns from <see cref="ReadAsync"/>. Mirrors the per-byte decode in
/// <see cref="FwlibFocasClient"/>'s <c>ReadPmc</c> + the bit-extract path so the
/// coalesced read is observably equivalent to a per-tag read.
/// </summary>
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,
};
/// <summary>One coalesced PMC slice — bytes the wire returned for one batch member + the status.</summary>
private readonly record struct PmcSlice(byte[] Bytes, uint Status);
/// <summary>
/// Apply <c>cnc_getfigure</c>-derived decimal scaling to a raw position value.
/// Returns <paramref name="raw"/> divided by <c>10^decimalPlaces</c> when the

View File

@@ -445,6 +445,42 @@ internal sealed class FwlibFocasClient : IFocasClient
return (value, FocasStatusMapper.Good);
}
/// <summary>
/// Range read for the PMC coalescer (issue #266). FWLIB's <c>pmc_rdpmcrng</c>
/// 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
/// <see cref="Wire.FocasPmcCoalescer"/>'s "one wire call per group" semantics.
/// </summary>
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<byte>(), 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;

View File

@@ -184,6 +184,45 @@ public interface IFocasClient : IDisposable
/// </summary>
Task SetPathAsync(int pathId, CancellationToken cancellationToken)
=> Task.CompletedTask;
/// <summary>
/// Read a contiguous range of PMC bytes in a single wire call (FOCAS
/// <c>pmc_rdpmcrng</c> with byte data type) for the given <paramref name="letter"/>
/// (<c>R</c>, <c>D</c>, <c>X</c>, etc.) starting at <paramref name="startByte"/> and
/// spanning <paramref name="byteCount"/> bytes. Returned tuple has the byte buffer
/// (length <paramref name="byteCount"/> on success) + the OPC UA status mapped through
/// <see cref="FocasStatusMapper"/>. Used by <see cref="FocasDriver"/> to coalesce
/// same-letter/same-path PMC reads in a batch into one round trip per range
/// (issue #266 — see <see cref="Wire.FocasPmcCoalescer"/>).
/// <para>
/// Default falls back to per-byte <see cref="ReadAsync(FocasAddress, FocasDataType, CancellationToken)"/>
/// 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.
/// </para>
/// </summary>
async Task<(byte[]? buffer, uint status)> ReadPmcRangeAsync(
string letter, int pathId, int startByte, int byteCount, CancellationToken cancellationToken)
{
if (byteCount <= 0) return (Array.Empty<byte>(), 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);
}
}
/// <summary>

View File

@@ -0,0 +1,152 @@
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Wire;
/// <summary>
/// One PMC byte-level read request a caller wants to satisfy. <see cref="ByteWidth"/> is
/// how many consecutive PMC bytes the caller's tag occupies (e.g. Bit/Byte = 1, Int16 = 2,
/// Int32/Float32 = 4, Float64 = 8). <see cref="OriginalIndex"/> 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.
/// </summary>
/// <remarks>
/// Bit-addressed PMC tags (e.g. <c>R100.3</c>) supply their parent byte (<c>100</c>) +
/// <c>ByteWidth = 1</c>; the slice-then-mask happens in the existing decode path, so
/// the coalescer doesn't need to know about the bit index.
/// </remarks>
public sealed record PmcAddressRequest(
string Letter,
int PathId,
int ByteNumber,
int ByteWidth,
int OriginalIndex);
/// <summary>
/// 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
/// <c>buffer[Offset .. Offset + ByteWidth]</c> to recover the per-tag wire-shape bytes.
/// </summary>
public sealed record PmcRangeMember(int OriginalIndex, int Offset, int ByteWidth);
/// <summary>
/// One coalesced PMC range — a single FOCAS <c>pmc_rdpmcrng</c> wire call satisfies
/// every member. <see cref="ByteCount"/> is bounded by <see cref="FocasPmcCoalescer.MaxRangeBytes"/>.
/// </summary>
public sealed record PmcRangeGroup(
string Letter,
int PathId,
int StartByte,
int ByteCount,
IReadOnlyList<PmcRangeMember> Members);
/// <summary>
/// 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 <see cref="MaxBridgeGap"/> are merged into a single wire call up to a
/// <see cref="MaxRangeBytes"/> cap (issue #266).
/// </summary>
/// <remarks>
/// <para>The cap matches the conservative ceiling Fanuc spec lists for
/// <c>pmc_rdpmcrng</c> — 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.</para>
///
/// <para>The <see cref="MaxBridgeGap"/> = 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.</para>
/// </remarks>
public static class FocasPmcCoalescer
{
/// <summary>Maximum bytes per coalesced range — conservative ceiling for older Fanuc firmware.</summary>
public const int MaxRangeBytes = 256;
/// <summary>Maximum gap (in bytes) bridged between consecutive sub-requests within a group.</summary>
public const int MaxBridgeGap = 16;
/// <summary>
/// Plan range reads from <paramref name="addresses"/>. Group key is
/// <c>(Letter, PathId)</c>. Within a group, requests are sorted by start byte then
/// greedily packed into ranges that respect <see cref="MaxRangeBytes"/> +
/// <see cref="MaxBridgeGap"/>.
/// </summary>
public static IReadOnlyList<PmcRangeGroup> Plan(IEnumerable<PmcAddressRequest> addresses)
{
ArgumentNullException.ThrowIfNull(addresses);
var groups = new List<PmcRangeGroup>();
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<PmcAddressRequest>();
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;
}
/// <summary>
/// The number of consecutive PMC bytes a tag of <paramref name="type"/> occupies on
/// the wire. Used by the driver to populate <see cref="PmcAddressRequest.ByteWidth"/>
/// before planning. Bit-addressed tags supply <c>1</c> here — the bit-extract happens
/// in the decode path after the slice.
/// </summary>
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,
};
}