Files
lmxopcua/src/ZB.MOM.WW.OtOpcUa.Driver.S7/S7BlockCoalescingPlanner.cs
2026-04-25 21:23:06 -04:00

242 lines
12 KiB
C#
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 &lt;=
/// <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}"),
};
}