Files
lmxopcua/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipMultiPacketReadPlanner.cs

133 lines
6.3 KiB
C#

namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
/// <summary>
/// PR abcip-3.3 — sparse-UDT read planner. Where <see cref="AbCipUdtReadPlanner"/> reads each
/// parent UDT once and decodes every subscribed member from the buffer in-memory, this planner
/// keeps the per-member read shape and bundles the reads into one CIP Multi-Service Packet
/// per parent so a 5-of-50-member subscription doesn't pay for the whole UDT buffer.
/// </summary>
/// <remarks>
/// <para>Pure function — like its sibling planner, this one never touches the runtime + never
/// reads the PLC. It produces the plan; <see cref="AbCipDriver"/> executes it.</para>
///
/// <para>The planner is intentionally <c>libplctag</c>-agnostic: the output is just a list of
/// <see cref="AbCipMultiPacketReadBatch"/> records that name the parent UDT, the per-member
/// read targets, and their byte offsets. The runtime layer decides whether to issue one
/// libplctag read per member (today's wrapper-limited fallback) or to flush the batch onto
/// one Multi-Service Packet (a future wrapper release). Either way the planner-tier logic
/// stays correct, which is why the unit tests in
/// <c>AbCipMultiPacketReadPlannerTests</c> assert plan shape rather than wire bytes.</para>
///
/// <para>Auto-mode dispatch (the heuristic): callers run <see cref="ChooseStrategyForGroup"/>
/// for each parent UDT to pick between the WholeUdt and MultiPacket paths per-group. The
/// heuristic divides <c>subscribedMembers / totalMembers</c> and picks MultiPacket when the
/// fraction is strictly less than the device's
/// <see cref="AbCipDeviceOptions.MultiPacketSparsityThreshold"/>.</para>
/// </remarks>
public static class AbCipMultiPacketReadPlanner
{
/// <summary>
/// Build a multi-packet read plan from <paramref name="requests"/>. Members of the same
/// parent UDT collapse into one <see cref="AbCipMultiPacketReadBatch"/>; references that
/// don't resolve to a UDT member fall back to <see cref="AbCipUdtReadFallback"/> for the
/// existing per-tag read path.
/// </summary>
public static AbCipMultiPacketReadPlan Build(
IReadOnlyList<string> requests,
IReadOnlyDictionary<string, AbCipTagDefinition> tagsByName)
{
ArgumentNullException.ThrowIfNull(requests);
ArgumentNullException.ThrowIfNull(tagsByName);
var fallback = new List<AbCipUdtReadFallback>(requests.Count);
var byParent = new Dictionary<string, List<AbCipUdtReadMember>>(StringComparer.OrdinalIgnoreCase);
for (var i = 0; i < requests.Count; i++)
{
var name = requests[i];
if (!tagsByName.TryGetValue(name, out var def))
{
fallback.Add(new AbCipUdtReadFallback(i, name));
continue;
}
var (parentName, memberName) = SplitParentMember(name);
if (parentName is null || memberName is null
|| !tagsByName.TryGetValue(parentName, out var parent)
|| parent.DataType != AbCipDataType.Structure
|| parent.Members is not { Count: > 0 })
{
fallback.Add(new AbCipUdtReadFallback(i, name));
continue;
}
var offsets = AbCipUdtMemberLayout.TryBuild(parent.Members);
if (offsets is null || !offsets.TryGetValue(memberName, out var offset))
{
fallback.Add(new AbCipUdtReadFallback(i, name));
continue;
}
if (!byParent.TryGetValue(parentName, out var members))
{
members = new List<AbCipUdtReadMember>();
byParent[parentName] = members;
}
members.Add(new AbCipUdtReadMember(i, def, offset));
}
var batches = new List<AbCipMultiPacketReadBatch>(byParent.Count);
foreach (var (parentName, members) in byParent)
{
batches.Add(new AbCipMultiPacketReadBatch(parentName, tagsByName[parentName], members));
}
return new AbCipMultiPacketReadPlan(batches, fallback);
}
/// <summary>
/// PR abcip-3.3 — Auto-mode heuristic. For a single parent UDT group with
/// <paramref name="subscribedMembers"/> of <paramref name="totalMembers"/> declared
/// members, pick <see cref="ReadStrategy.MultiPacket"/> when sparsity is strictly below
/// <paramref name="threshold"/>, else <see cref="ReadStrategy.WholeUdt"/>. Threshold is
/// clamped to <c>[0..1]</c>; out-of-range values saturate. Edge cases:
/// <c>totalMembers == 0</c> defaults to <see cref="ReadStrategy.WholeUdt"/> (the
/// historical behaviour) so a misconfigured tag map doesn't fault the read.
/// </summary>
public static ReadStrategy ChooseStrategyForGroup(int subscribedMembers, int totalMembers, double threshold)
{
if (totalMembers <= 0) return ReadStrategy.WholeUdt;
// Saturate the threshold to a sane range. 0.0 → never MultiPacket; 1.0 → always
// MultiPacket whenever any member is subscribed (deterministic boundary behaviour).
var t = threshold;
if (t < 0.0) t = 0.0;
if (t > 1.0) t = 1.0;
var fraction = (double)subscribedMembers / totalMembers;
return fraction < t ? ReadStrategy.MultiPacket : ReadStrategy.WholeUdt;
}
private static (string? Parent, string? Member) SplitParentMember(string reference)
{
var dot = reference.IndexOf('.');
if (dot <= 0 || dot == reference.Length - 1) return (null, null);
return (reference[..dot], reference[(dot + 1)..]);
}
}
/// <summary>A planner output: per-parent multi-packet batches + per-tag fallbacks.</summary>
public sealed record AbCipMultiPacketReadPlan(
IReadOnlyList<AbCipMultiPacketReadBatch> Batches,
IReadOnlyList<AbCipUdtReadFallback> Fallbacks);
/// <summary>
/// One UDT parent whose subscribed members are bundled into a Multi-Service Packet read.
/// Reuses <see cref="AbCipUdtReadMember"/> from the WholeUdt planner so callers can decode
/// the member offsets uniformly across both planners.
/// </summary>
public sealed record AbCipMultiPacketReadBatch(
string ParentName,
AbCipTagDefinition ParentDefinition,
IReadOnlyList<AbCipUdtReadMember> Members);