241
src/ZB.MOM.WW.OtOpcUa.Driver.S7/S7BlockCoalescingPlanner.cs
Normal file
241
src/ZB.MOM.WW.OtOpcUa.Driver.S7/S7BlockCoalescingPlanner.cs
Normal file
@@ -0,0 +1,241 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.S7;
|
||||
|
||||
/// <summary>
|
||||
/// Block-read coalescing planner for the S7 driver (PR-S7-B2). Where the
|
||||
/// <see cref="S7ReadPacker"/> coalesces N scalar tags into ⌈N/19⌉
|
||||
/// <c>Plc.ReadMultipleVarsAsync</c> PDUs, this planner takes one further pass:
|
||||
/// it groups same-area, same-DB tags by contiguous byte range and folds them
|
||||
/// into a single <c>Plc.ReadBytesAsync</c> covering the merged span. The
|
||||
/// response is sliced client-side per tag so the per-tag decode path is
|
||||
/// unchanged.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Why coalesce</b>: Reading <c>DB1.DBW0</c> + <c>DB1.DBW2</c> +
|
||||
/// <c>DB1.DBW4</c> as three multi-var items still uses three slots in a
|
||||
/// single PDU; coalescing into one 6-byte byte-range read drops the per-item
|
||||
/// framing entirely and makes the request fit in fewer (sometimes zero
|
||||
/// additional) PDUs. On a typical contiguous DB the wire-level reduction is
|
||||
/// 50:1 for 50 contiguous DBWs.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Gap-merge threshold</b>: The planner merges adjacent tag ranges when
|
||||
/// the gap between them is at most the <c>gapMergeBytes</c> argument to
|
||||
/// <see cref="Plan"/>. The default <see cref="DefaultGapMergeBytes"/> is
|
||||
/// 16 bytes — over-fetching 16 bytes is cheaper than one extra PDU
|
||||
/// (240-byte default PDU envelope, ~18 bytes per request frame). Operators
|
||||
/// can tune the threshold per driver instance via
|
||||
/// <see cref="S7DriverOptions.BlockCoalescingGapBytes"/>.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Opaque-size opt-out</b>: STRING / WSTRING / CHAR / WCHAR and DTL /
|
||||
/// DT / S5TIME / TIME / TOD / DATE-as-DateTime tags carry a header (or
|
||||
/// have a per-tag width that varies with <c>StringLength</c>) and are
|
||||
/// flagged <c>OpaqueSize=true</c>. The planner emits these as standalone
|
||||
/// single-tag ranges and never merges them into a sibling block — the
|
||||
/// per-tag decode path needs an exact byte slice and a wrong slice from
|
||||
/// a coalesced read would silently corrupt every neighbour.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Order-preserving</b>: Each <see cref="BlockReadRange"/> carries a list
|
||||
/// of <see cref="TagSlice"/> values pointing back at the original
|
||||
/// caller-index. The driver's <c>ReadAsync</c> uses the index to write the
|
||||
/// decoded value into the correct slot of the result array, so caller
|
||||
/// ordering of the input <c>fullReferences</c> is preserved across the
|
||||
/// coalescing step.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal static class S7BlockCoalescingPlanner
|
||||
{
|
||||
/// <summary>Default gap-merge threshold in bytes.</summary>
|
||||
internal const int DefaultGapMergeBytes = 16;
|
||||
|
||||
/// <summary>
|
||||
/// One coalesced byte-range request. The driver issues a single
|
||||
/// <c>Plc.ReadBytesAsync</c> covering <see cref="StartByte"/>..
|
||||
/// <see cref="StartByte"/>+<see cref="ByteCount"/>; each entry in
|
||||
/// <see cref="Tags"/> carries the offset within the response buffer to
|
||||
/// slice for that tag.
|
||||
/// </summary>
|
||||
internal sealed record BlockReadRange(
|
||||
S7Area Area,
|
||||
int DbNumber,
|
||||
int StartByte,
|
||||
int ByteCount,
|
||||
IReadOnlyList<TagSlice> Tags);
|
||||
|
||||
/// <summary>
|
||||
/// One tag's slot inside a <see cref="BlockReadRange"/>. <see cref="OffsetInBlock"/>
|
||||
/// is the byte offset within the coalesced buffer; <see cref="ByteCount"/> is the
|
||||
/// per-tag width that the slice covers.
|
||||
/// </summary>
|
||||
/// <param name="CallerIndex">Original index in the caller's <c>fullReferences</c> list.</param>
|
||||
/// <param name="OffsetInBlock">Byte offset into <see cref="BlockReadRange"/>'s buffer.</param>
|
||||
/// <param name="ByteCount">Bytes the tag claims from the buffer.</param>
|
||||
internal sealed record TagSlice(int CallerIndex, int OffsetInBlock, int ByteCount);
|
||||
|
||||
/// <summary>
|
||||
/// Input row. Captures everything the planner needs to make a coalescing
|
||||
/// decision without needing the full <see cref="S7TagDefinition"/> graph.
|
||||
/// </summary>
|
||||
/// <param name="CallerIndex">Caller-supplied stable index used to thread the decoded value back.</param>
|
||||
/// <param name="Area">Memory area; M and DB never merge into the same range.</param>
|
||||
/// <param name="DbNumber">DB number when <see cref="Area"/> is DataBlock; 0 otherwise.</param>
|
||||
/// <param name="StartByte">Byte offset in the area where the tag's storage begins.</param>
|
||||
/// <param name="ByteCount">On-wire byte width of the tag.</param>
|
||||
/// <param name="OpaqueSize">
|
||||
/// True for tags whose effective decode width is variable / header-prefixed
|
||||
/// (STRING/WSTRING/CHAR/WCHAR and structured timestamps DTL/DT/etc.) so the
|
||||
/// planner skips them — they emit standalone reads and never merge with
|
||||
/// neighbours.
|
||||
/// </param>
|
||||
internal sealed record TagSpec(
|
||||
int CallerIndex,
|
||||
S7Area Area,
|
||||
int DbNumber,
|
||||
int StartByte,
|
||||
int ByteCount,
|
||||
bool OpaqueSize);
|
||||
|
||||
/// <summary>
|
||||
/// Plan a list of byte-range reads from <paramref name="tags"/>. Same-area /
|
||||
/// same-DB rows are sorted by <see cref="TagSpec.StartByte"/> then merged
|
||||
/// greedily when the gap between their byte ranges is <=
|
||||
/// <paramref name="gapMergeBytes"/>. Opaque-size rows always emit as their
|
||||
/// own single-tag range and never extend a sibling block.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Order of returned ranges is not significant — the driver issues them
|
||||
/// sequentially against the same connection gate so wire-level ordering is
|
||||
/// determined by the loop, not by this list. The planner DOES preserve
|
||||
/// the caller-index inside each range so the per-tag decode result lands
|
||||
/// in the correct slot of the response array.
|
||||
/// </remarks>
|
||||
internal static List<BlockReadRange> Plan(IReadOnlyList<TagSpec> tags, int gapMergeBytes = DefaultGapMergeBytes)
|
||||
{
|
||||
if (gapMergeBytes < 0)
|
||||
throw new ArgumentOutOfRangeException(nameof(gapMergeBytes), "Gap-merge threshold must be non-negative.");
|
||||
var ranges = new List<BlockReadRange>(tags.Count);
|
||||
if (tags.Count == 0) return ranges;
|
||||
|
||||
// Phase 1: opaque rows emit as standalone single-tag ranges. Strip them
|
||||
// out of the merge candidate set so neighbour ranges don't accidentally
|
||||
// straddle a STRING header / DTL block.
|
||||
var mergeable = new List<TagSpec>(tags.Count);
|
||||
foreach (var t in tags)
|
||||
{
|
||||
if (t.OpaqueSize)
|
||||
{
|
||||
ranges.Add(new BlockReadRange(
|
||||
t.Area, t.DbNumber, t.StartByte, t.ByteCount,
|
||||
[new TagSlice(t.CallerIndex, OffsetInBlock: 0, t.ByteCount)]));
|
||||
}
|
||||
else
|
||||
{
|
||||
mergeable.Add(t);
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 2: bucket by (Area, DbNumber). Memory M and DataBlock DB1 (etc.)
|
||||
// share neither the wire request type nor an addressable space, so they
|
||||
// can never coalesce.
|
||||
var groups = mergeable.GroupBy(t => (t.Area, t.DbNumber));
|
||||
foreach (var group in groups)
|
||||
{
|
||||
// Sort ascending by start byte so the greedy merge below is O(n).
|
||||
// Stable secondary sort on caller index keeps tag-slice ordering
|
||||
// deterministic for tags with identical byte offsets.
|
||||
var sorted = group
|
||||
.OrderBy(t => t.StartByte)
|
||||
.ThenBy(t => t.CallerIndex)
|
||||
.ToList();
|
||||
|
||||
var blockStart = sorted[0].StartByte;
|
||||
var blockEnd = sorted[0].StartByte + sorted[0].ByteCount;
|
||||
var blockSlices = new List<TagSlice>
|
||||
{
|
||||
new(sorted[0].CallerIndex, 0, sorted[0].ByteCount),
|
||||
};
|
||||
|
||||
for (var i = 1; i < sorted.Count; i++)
|
||||
{
|
||||
var t = sorted[i];
|
||||
var gap = t.StartByte - blockEnd;
|
||||
// gap < 0 means the next tag overlaps with the current block — treat
|
||||
// as zero-gap merge (overlap is fine, the slice just reuses earlier
|
||||
// bytes). gap <= threshold = merge; otherwise close the current
|
||||
// block and start a new one.
|
||||
if (gap <= gapMergeBytes)
|
||||
{
|
||||
var newEnd = Math.Max(blockEnd, t.StartByte + t.ByteCount);
|
||||
blockSlices.Add(new TagSlice(t.CallerIndex, t.StartByte - blockStart, t.ByteCount));
|
||||
blockEnd = newEnd;
|
||||
}
|
||||
else
|
||||
{
|
||||
ranges.Add(new BlockReadRange(
|
||||
group.Key.Area, group.Key.DbNumber, blockStart, blockEnd - blockStart, blockSlices));
|
||||
blockStart = t.StartByte;
|
||||
blockEnd = t.StartByte + t.ByteCount;
|
||||
blockSlices = [new TagSlice(t.CallerIndex, 0, t.ByteCount)];
|
||||
}
|
||||
}
|
||||
ranges.Add(new BlockReadRange(
|
||||
group.Key.Area, group.Key.DbNumber, blockStart, blockEnd - blockStart, blockSlices));
|
||||
}
|
||||
|
||||
return ranges;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// True when <paramref name="tag"/>'s on-wire width is variable / header-prefixed.
|
||||
/// Such tags MUST NOT participate in block coalescing because the slice into a
|
||||
/// coalesced byte buffer would land at a wrong offset for any neighbour.
|
||||
/// </summary>
|
||||
internal static bool IsOpaqueSize(S7TagDefinition tag)
|
||||
{
|
||||
// Variable-width string types — STRING/WSTRING carry a 2-byte (or 4-byte)
|
||||
// header and the actual length depends on the runtime value, not the
|
||||
// declared StringLength. CHAR/WCHAR are fixed-width (1 / 2 bytes) but
|
||||
// routed via the per-tag string codec path, so coalescing them would
|
||||
// bypass the codec; treat them as opaque to keep the decode surface
|
||||
// unchanged.
|
||||
if (tag.DataType is S7DataType.String or S7DataType.WString
|
||||
or S7DataType.Char or S7DataType.WChar)
|
||||
return true;
|
||||
|
||||
// Structured timestamps — DTL is 12 bytes, DT is 8 bytes BCD-encoded;
|
||||
// both decode through S7DateTimeCodec and would silently mis-decode if
|
||||
// the slice landed mid-block. S5TIME/TIME/TOD/DATE are fixed-width 2/4
|
||||
// bytes but currently flow through the per-tag codec path; treat them
|
||||
// all as opaque so the planner emits a single-tag range and the existing
|
||||
// codec dispatch stays the source of truth for date/time decode.
|
||||
if (tag.DataType is S7DataType.Dtl or S7DataType.DateAndTime
|
||||
or S7DataType.S5Time or S7DataType.Time or S7DataType.TimeOfDay or S7DataType.Date)
|
||||
return true;
|
||||
|
||||
// Arrays opt out: per-tag width is N × elementBytes, the slice must be
|
||||
// exact. Routing them as opaque keeps the array-aware byte-range read
|
||||
// path in S7Driver.ReadOneAsync.
|
||||
if (tag.ElementCount is int n && n > 1)
|
||||
return true;
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Byte width of a packable scalar tag for byte-range coalescing. Mirrors the
|
||||
/// size suffix the address grammar carried (<see cref="S7Size.Bit"/>=1 byte
|
||||
/// because reading a single bit still requires reading the containing byte;
|
||||
/// bit-extraction happens in the slice step).
|
||||
/// </summary>
|
||||
internal static int ScalarByteCount(S7Size size) => size switch
|
||||
{
|
||||
S7Size.Bit => 1,
|
||||
S7Size.Byte => 1,
|
||||
S7Size.Word => 2,
|
||||
S7Size.DWord => 4,
|
||||
S7Size.LWord => 8,
|
||||
_ => throw new InvalidOperationException($"Unknown S7Size {size}"),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user