namespace ZB.MOM.WW.OtOpcUa.Driver.S7; /// /// Block-read coalescing planner for the S7 driver (PR-S7-B2). Where the /// coalesces N scalar tags into ⌈N/19⌉ /// Plc.ReadMultipleVarsAsync PDUs, this planner takes one further pass: /// it groups same-area, same-DB tags by contiguous byte range and folds them /// into a single Plc.ReadBytesAsync covering the merged span. The /// response is sliced client-side per tag so the per-tag decode path is /// unchanged. /// /// /// /// Why coalesce: Reading DB1.DBW0 + DB1.DBW2 + /// DB1.DBW4 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. /// /// /// Gap-merge threshold: The planner merges adjacent tag ranges when /// the gap between them is at most the gapMergeBytes argument to /// . The default 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 /// . /// /// /// Opaque-size opt-out: 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 StringLength) and are /// flagged OpaqueSize=true. 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. /// /// /// Order-preserving: Each carries a list /// of values pointing back at the original /// caller-index. The driver's ReadAsync uses the index to write the /// decoded value into the correct slot of the result array, so caller /// ordering of the input fullReferences is preserved across the /// coalescing step. /// /// internal static class S7BlockCoalescingPlanner { /// Default gap-merge threshold in bytes. internal const int DefaultGapMergeBytes = 16; /// /// One coalesced byte-range request. The driver issues a single /// Plc.ReadBytesAsync covering .. /// +; each entry in /// carries the offset within the response buffer to /// slice for that tag. /// internal sealed record BlockReadRange( S7Area Area, int DbNumber, int StartByte, int ByteCount, IReadOnlyList Tags); /// /// One tag's slot inside a . /// is the byte offset within the coalesced buffer; is the /// per-tag width that the slice covers. /// /// Original index in the caller's fullReferences list. /// Byte offset into 's buffer. /// Bytes the tag claims from the buffer. internal sealed record TagSlice(int CallerIndex, int OffsetInBlock, int ByteCount); /// /// Input row. Captures everything the planner needs to make a coalescing /// decision without needing the full graph. /// /// Caller-supplied stable index used to thread the decoded value back. /// Memory area; M and DB never merge into the same range. /// DB number when is DataBlock; 0 otherwise. /// Byte offset in the area where the tag's storage begins. /// On-wire byte width of the tag. /// /// 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. /// internal sealed record TagSpec( int CallerIndex, S7Area Area, int DbNumber, int StartByte, int ByteCount, bool OpaqueSize); /// /// Plan a list of byte-range reads from . Same-area / /// same-DB rows are sorted by then merged /// greedily when the gap between their byte ranges is <= /// . Opaque-size rows always emit as their /// own single-tag range and never extend a sibling block. /// /// /// 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. /// internal static List Plan(IReadOnlyList tags, int gapMergeBytes = DefaultGapMergeBytes) { if (gapMergeBytes < 0) throw new ArgumentOutOfRangeException(nameof(gapMergeBytes), "Gap-merge threshold must be non-negative."); var ranges = new List(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(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 { 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; } /// /// True when '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. /// 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; } /// /// Byte width of a packable scalar tag for byte-range coalescing. Mirrors the /// size suffix the address grammar carried (=1 byte /// because reading a single bit still requires reading the containing byte; /// bit-extraction happens in the slice step). /// 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}"), }; }