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}"),
};
}