Files
lmxopcua/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7/S7ReadSpanPlanner.cs
T

199 lines
10 KiB
C#

namespace ZB.MOM.WW.OtOpcUa.Driver.S7;
/// <summary>
/// Pure planner that turns one read batch into contiguous <c>(area, DB)</c> byte spans, so the
/// driver can serve many tags with one <c>ReadBytesAsync</c> instead of one wire request per
/// tag. No I/O, no driver state — the grouping rules are decided here and executed by
/// <see cref="S7Driver.ReadAsync"/>.
/// </summary>
/// <remarks>
/// <para>
/// <b>The safety invariant is that a span never extends past its last member's end.</b> A
/// span may deliberately read bytes nobody asked for BETWEEN its members (that is what the
/// gap budget buys), but every byte it requests lies inside a range some member was going
/// to read anyway. A grouped read therefore cannot fault (area overrun, DB overrun) where
/// the individual per-tag reads would have succeeded.
/// </para>
/// <para>
/// <b>Timer and Counter tags are never grouped.</b> Their
/// <see cref="S7ParsedAddress.ByteOffset"/> holds the timer/counter DEVICE NUMBER rather
/// than a byte address, and S7.Net addresses those areas in per-device units — merging one
/// with a byte-addressed neighbour, or even with another Timer, would read from a
/// completely unrelated place. They come back as leftovers for the per-tag path.
/// </para>
/// </remarks>
internal static class S7ReadSpanPlanner
{
/// <summary>One tag inside a planned span, positioned relative to the span's start byte.</summary>
/// <param name="OriginalIndex">The tag's index in the caller's read batch — where its result must be written.</param>
/// <param name="Tag">The resolved tag definition (carries the data type, array count and string length).</param>
/// <param name="Addr">The tag's parsed address (carries the area, DB, access size and bit position).</param>
/// <param name="WidthBytes">Bytes this tag occupies — the exact number its decoder consumes.</param>
/// <param name="RelativeOffset">The tag's first byte expressed against <see cref="Span.StartByte"/>; the slice offset into the block.</param>
internal sealed record Member(
int OriginalIndex,
S7TagDefinition Tag,
S7ParsedAddress Addr,
int WidthBytes,
int RelativeOffset);
/// <summary>One planned block read: a contiguous byte range in one area, serving several tags.</summary>
/// <param name="Area">The S7 area the block is read from.</param>
/// <param name="DbNumber">Data-block number (0 for non-DB areas).</param>
/// <param name="StartByte">First byte of the block — the lowest member's byte offset.</param>
/// <param name="LengthBytes">Block length — always exactly the furthest member's end minus <paramref name="StartByte"/>.</param>
/// <param name="Members">The tags this block serves, in ascending byte order.</param>
internal sealed record Span(
S7Area Area,
int DbNumber,
int StartByte,
int LengthBytes,
IReadOnlyList<Member> Members);
/// <summary>The planned grouping of one read batch.</summary>
/// <param name="Spans">Block reads to issue, one wire request each.</param>
/// <param name="LeftoverIndexes">Batch indexes the planner declined — the caller reads these one at a time.</param>
internal sealed record Plan(IReadOnlyList<Span> Spans, IReadOnlyList<int> LeftoverIndexes);
/// <summary>
/// Groups the resolved tags of one read batch into block-read spans.
/// </summary>
/// <param name="resolved">
/// The batch's resolved tags, each paired with its parsed address and its index in the
/// caller's reference list. Unresolvable references must be handled by the caller and left
/// out of this list.
/// </param>
/// <param name="maxGapBytes">
/// Largest hole to read through when merging (inclusive). <c>0</c> merges only strictly
/// adjacent or overlapping tags; any value below zero disables grouping and returns every
/// index as a leftover.
/// </param>
/// <param name="maxSpanBytes">
/// Largest planned span. A run that would exceed it is broken; a single tag wider than it
/// still forms its own span, because a tag is never split across requests.
/// </param>
/// <returns>The spans to read plus the indexes the caller must read individually.</returns>
internal static Plan Build(
IReadOnlyList<(int OriginalIndex, S7TagDefinition Tag, S7ParsedAddress Addr)> resolved,
int maxGapBytes,
int maxSpanBytes)
{
ArgumentNullException.ThrowIfNull(resolved);
var leftovers = new List<int>();
// The off switch. Checked here (as well as at the call site) so the disabled behaviour is
// a property of the planner and can be proven without standing up a driver.
if (maxGapBytes < 0)
{
foreach (var (index, _, _) in resolved) leftovers.Add(index);
return new Plan([], leftovers);
}
List<(int Index, S7TagDefinition Tag, S7ParsedAddress Addr, int Width)>? eligible = null;
foreach (var (index, tag, addr) in resolved)
{
// Timer/Counter offsets are device numbers, not byte addresses — see the type remarks.
if (addr.Area is S7Area.Timer or S7Area.Counter)
{
leftovers.Add(index);
continue;
}
(eligible ??= []).Add((index, tag, addr, MemberWidthBytes(tag, addr)));
}
if (eligible is null) return new Plan([], leftovers);
var spans = new List<Span>();
// Group by (area, DB): two areas — or two data blocks — are separate address spaces, so a
// byte range in one says nothing about the same range in the other.
foreach (var group in eligible.GroupBy(e => (e.Addr.Area, e.Addr.DbNumber)))
{
OpenSpan? open = null;
// Ascending byte order. OrderBy is stable, so tags sharing a byte (two Bools at
// different bit positions) keep their batch order inside the span.
foreach (var (index, tag, addr, width) in group.OrderBy(e => e.Addr.ByteOffset))
{
var start = addr.ByteOffset;
var end = start + width; // exclusive
if (open is not null)
{
// A negative gap means the tags overlap (a word inside an already-open dword,
// or a second Bool in the same byte) — that is a gap of zero or better, so it
// always passes a non-negative budget without any clamping.
var gap = start - open.EndByte;
var mergedEnd = Math.Max(open.EndByte, end);
if (gap <= maxGapBytes && mergedEnd - open.StartByte <= maxSpanBytes)
{
open.Members.Add(new Member(index, tag, addr, width, start - open.StartByte));
// Max, not `end`: an overlapping member must never pull the span's end back.
open.EndByte = mergedEnd;
continue;
}
spans.Add(Seal(group.Key.Area, group.Key.DbNumber, open));
}
// Start a fresh span. Deliberately unconditional — a tag wider than maxSpanBytes
// gets its own oversized span rather than being split, and because the merge test
// above measures the RESULTING length, that oversized span can never absorb a
// neighbour afterwards.
open = new OpenSpan(start, end);
open.Members.Add(new Member(index, tag, addr, width, 0));
}
if (open is not null) spans.Add(Seal(group.Key.Area, group.Key.DbNumber, open));
}
return new Plan(spans, leftovers);
}
/// <summary>
/// Bytes one tag occupies inside a contiguous block. The three arms are checked in exactly
/// the order <see cref="S7Driver.ReadAsync"/> routes a tag to its decoder, so the width
/// planned here is always the width the decoder that runs will consume.
/// </summary>
/// <param name="tag">The resolved tag definition.</param>
/// <param name="addr">The tag's parsed address.</param>
/// <returns>The tag's byte width.</returns>
internal static int MemberWidthBytes(S7TagDefinition tag, S7ParsedAddress addr)
{
if (tag.ArrayCount is >= 1)
return tag.ArrayCount.Value * S7Driver.ElementByteSize(addr.Size);
if (S7Driver.IsBufferType(tag, addr))
return S7Driver.ScalarByteWidth(tag, addr);
return S7Driver.NarrowScalarWidth(addr);
}
/// <summary>Freezes an in-progress span into its immutable <see cref="Span"/> form.</summary>
/// <param name="area">The area the span reads from.</param>
/// <param name="dbNumber">The data-block number.</param>
/// <param name="open">The in-progress span.</param>
/// <returns>The sealed span; its length is exactly the furthest member's end minus the start.</returns>
private static Span Seal(S7Area area, int dbNumber, OpenSpan open)
=> new(area, dbNumber, open.StartByte, open.EndByte - open.StartByte, open.Members);
/// <summary>
/// Mutable accumulator for a span still being extended. <see cref="StartByte"/> is fixed at
/// creation (members arrive in ascending order, so nothing can ever start earlier);
/// <see cref="EndByte"/> is exclusive and only ever grows.
/// </summary>
/// <param name="startByte">First byte of the span.</param>
/// <param name="endByte">Exclusive end byte of the span so far.</param>
private sealed class OpenSpan(int startByte, int endByte)
{
/// <summary>First byte of the span — fixed for the span's lifetime.</summary>
public int StartByte { get; } = startByte;
/// <summary>Exclusive end byte of the furthest member seen so far.</summary>
public int EndByte { get; set; } = endByte;
/// <summary>Members accumulated so far, in ascending byte order.</summary>
public List<Member> Members { get; } = [];
}
}