Merge pull request '[focas] FOCAS — Bulk PMC range read coalescing' (#357) from auto/focas/F2-d into auto/driver-gaps
This commit was merged in pull request #357.
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
152
src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/Wire/FocasPmcCoalescer.cs
Normal file
152
src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/Wire/FocasPmcCoalescer.cs
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -80,6 +80,33 @@ internal class FakeFocasClient : IFocasClient
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Per-letter / per-path byte storage the coalesced range path reads from. Tests
|
||||
/// populate <c>PmcByteRanges[("R", 1)] = new byte[size]</c> + the corresponding values to
|
||||
/// drive both the per-tag <see cref="ReadAsync"/> + the coalesced
|
||||
/// <see cref="ReadPmcRangeAsync"/> path against the same source of truth (issue #266).
|
||||
/// </summary>
|
||||
public Dictionary<(string Letter, int PathId), byte[]> PmcByteRanges { get; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Ordered log of <c>pmc_rdpmcrng</c>-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).
|
||||
/// </summary>
|
||||
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++;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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<PmcAddressRequest>());
|
||||
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<PmcAddressRequest>();
|
||||
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<PmcAddressRequest>();
|
||||
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<PmcAddressRequest>
|
||||
{
|
||||
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<PmcAddressRequest>
|
||||
{
|
||||
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<PmcAddressRequest>
|
||||
{
|
||||
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<PmcAddressRequest>
|
||||
{
|
||||
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<PmcAddressRequest>
|
||||
{
|
||||
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<PmcAddressRequest>
|
||||
{
|
||||
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user