Auto: abcip-3.3 — read-strategy selector (WholeUdt / MultiPacket / Auto)

Closes #237
This commit is contained in:
Joseph Doherty
2026-04-25 23:16:06 -04:00
parent 8a8dc1ee5a
commit 01f4ee6b53
9 changed files with 1093 additions and 11 deletions

View File

@@ -157,7 +157,13 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
// unsupported family falls back to Symbolic + emits a warning so misconfiguration
// does not fault the driver.
var resolvedAddressing = ResolveAddressingMode(device, profile);
_devices[device.HostAddress] = new DeviceState(addr, device, profile, resolvedAddressing);
// PR abcip-3.3 — resolve ReadStrategy at the device level. User-forced MultiPacket
// against a non-packing family (Micro800 et al) falls back to WholeUdt with a
// warning. Auto stays as-is — the planner re-evaluates per-batch using the
// device's MultiPacketSparsityThreshold.
var resolvedReadStrategy = ResolveReadStrategy(device, profile);
_devices[device.HostAddress] = new DeviceState(
addr, device, profile, resolvedAddressing, resolvedReadStrategy);
}
// Pre-declared tags first; L5K imports fill in only the names not already covered
// (operators can override an imported entry by re-declaring it under Tags).
@@ -268,6 +274,46 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
}
}
/// <summary>
/// PR abcip-3.3 — resolve <see cref="AbCipDeviceOptions.ReadStrategy"/> against the
/// family profile. <see cref="ReadStrategy.MultiPacket"/> against a family whose profile
/// sets <see cref="AbCipPlcFamilyProfile.SupportsRequestPacking"/> = <c>false</c>
/// (Micro800 today; SLC500 / PLC5 when those profiles ship) falls back to
/// <see cref="ReadStrategy.WholeUdt"/> with a warning so the operator sees the
/// misconfiguration in the log without the driver faulting. <see cref="ReadStrategy.Auto"/>
/// stays as-is — the planner re-evaluates the choice per-batch from the device's
/// <see cref="AbCipDeviceOptions.MultiPacketSparsityThreshold"/>; we still need the
/// resolution step here so a future PR can cap Auto at WholeUdt on non-packing families
/// in one place rather than scattering the check across the read path.
/// </summary>
private ReadStrategy ResolveReadStrategy(AbCipDeviceOptions device, AbCipPlcFamilyProfile profile)
{
switch (device.ReadStrategy)
{
case ReadStrategy.MultiPacket:
if (!profile.SupportsRequestPacking)
{
_options.OnWarning?.Invoke(
$"AbCip device '{device.HostAddress}' family '{device.PlcFamily}' does not support " +
"Multi-Service Packet request packing — its CIP firmware lacks the 0x0A service. " +
"Falling back to WholeUdt read strategy for this device.");
return ReadStrategy.WholeUdt;
}
return ReadStrategy.MultiPacket;
case ReadStrategy.WholeUdt:
return ReadStrategy.WholeUdt;
case ReadStrategy.Auto:
default:
// Auto on a non-packing family stays Auto here so the planner's per-batch
// heuristic still runs; the heuristic itself never picks MultiPacket against a
// device whose AddressingMode-style guard tripped — but for ReadStrategy the guard
// lives in the device-init resolution above (user-forced MultiPacket → WholeUdt).
// For Auto, the planner consults device.Profile.SupportsRequestPacking before
// emitting MultiPacket so non-packing families always read WholeUdt under Auto.
return ReadStrategy.Auto;
}
}
/// <summary>
/// Shared L5K / L5X import path — keeps source-format selection (parser delegate) the
/// only behavioural axis between the two formats. Adds the parser's tags to
@@ -530,17 +576,187 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
// per-tag path that's been here since PR 3. Planner is a pure function over the
// current tag map; BOOL/String/Structure members stay on the fallback path because
// declaration-only offsets can't place them under Logix alignment rules.
var plan = AbCipUdtReadPlanner.Build(fullReferences, _tagsByName);
foreach (var group in plan.Groups)
await ReadGroupAsync(group, results, now, cancellationToken).ConfigureAwait(false);
foreach (var fb in plan.Fallbacks)
await ReadSingleAsync(fb, fullReferences[fb.OriginalIndex], results, now, cancellationToken).ConfigureAwait(false);
//
// PR abcip-3.3 — dispatch by device's resolved ReadStrategy:
// WholeUdt — every group goes through the whole-UDT planner (task #194 default).
// MultiPacket — every group goes through the multi-packet planner; one read per
// subscribed member, bundled per parent.
// Auto — per-group heuristic on subscribedMembers / totalMembers.
await ExecuteReadPlanAsync(fullReferences, results, now, cancellationToken).ConfigureAwait(false);
return results;
}
/// <summary>
/// PR abcip-3.3 — strategy-aware dispatch wrapper around the WholeUdt + MultiPacket
/// planners. Both planners produce the same shape of "groups + per-tag fallbacks" so
/// fallbacks always run through <see cref="ReadSingleAsync"/>; only the group shape
/// differs. Auto resolves per-group: members of the same parent UDT either flow through
/// <see cref="ReadGroupAsync"/> (one whole-UDT read) or
/// <see cref="ReadMultiPacketBatchAsync"/> (per-member reads bundled per parent).
/// </summary>
private async Task ExecuteReadPlanAsync(
IReadOnlyList<string> fullReferences, DataValueSnapshot[] results, DateTime now, CancellationToken ct)
{
// First pass — segregate references by parent UDT vs everything-else, identical to the
// shape both planners produce; we can then route each parent group through the chosen
// planner. Reuse AbCipUdtReadPlanner.Build for the WholeUdt+Auto path because it already
// demotes single-member groups to fallback, and the MultiPacket planner does NOT demote
// (sparse reads of one member are still a win on the wire).
var wholeUdtPlan = AbCipUdtReadPlanner.Build(fullReferences, _tagsByName);
var multiPacketPlan = AbCipMultiPacketReadPlanner.Build(fullReferences, _tagsByName);
// Determine per-parent strategy. WholeUdt planner emits a Group only when ≥2 members of
// the same parent are subscribed; MultiPacket planner emits a Batch for every parent
// touched. Index multiPacket batches by parent name so we can co-route them with the
// WholeUdt grouping decisions.
var multiPacketByParent = new Dictionary<string, AbCipMultiPacketReadBatch>(
multiPacketPlan.Batches.Count, StringComparer.OrdinalIgnoreCase);
foreach (var b in multiPacketPlan.Batches)
multiPacketByParent[b.ParentName] = b;
// Treat each parent that survived the WholeUdt planner as the candidate for whole-UDT
// dispatch; per-strategy routing decides whether it goes WholeUdt or MultiPacket.
var routedParents = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var group in wholeUdtPlan.Groups)
{
_devices.TryGetValue(group.ParentDefinition.DeviceHostAddress, out var device);
var strategy = ChooseEffectiveStrategy(device, group);
if (strategy == ReadStrategy.MultiPacket
&& multiPacketByParent.TryGetValue(group.ParentName, out var mpBatch))
{
if (device is not null) Interlocked.Increment(ref device.MultiPacketGroupsExecuted);
await ReadMultiPacketBatchAsync(mpBatch, results, now, ct).ConfigureAwait(false);
}
else
{
if (device is not null) Interlocked.Increment(ref device.WholeUdtGroupsExecuted);
await ReadGroupAsync(group, results, now, ct).ConfigureAwait(false);
}
routedParents.Add(group.ParentName);
}
// Per-tag fallbacks from the WholeUdt planner are the union of (a) tags with no UDT
// parent, (b) UDT members where the parent had only one subscribed member, and (c)
// unknown references. We re-route singletons through MultiPacket too when the device
// strategy is MultiPacket, because a 1-of-50 read is exactly the sparse case the planner
// is designed for.
foreach (var fb in wholeUdtPlan.Fallbacks)
{
// Is this a UDT-member fallback that the MultiPacket planner can route?
if (multiPacketPlan.Batches.Count > 0
&& _tagsByName.TryGetValue(fb.Reference, out var def))
{
var dot = fb.Reference.IndexOf('.');
if (dot > 0 && dot < fb.Reference.Length - 1)
{
var parentName = fb.Reference[..dot];
if (!routedParents.Contains(parentName)
&& multiPacketByParent.TryGetValue(parentName, out var mpBatch)
&& _devices.TryGetValue(def.DeviceHostAddress, out var device))
{
// Singleton parent — Auto picks based on the same heuristic; MultiPacket
// wins by default because the WholeUdt planner already demoted single
// members under the assumption that one whole-UDT read is no cheaper
// than one member read. With explicit MultiPacket the answer flips.
var strategy = ChooseEffectiveStrategyForSingleton(device, mpBatch);
if (strategy == ReadStrategy.MultiPacket)
{
Interlocked.Increment(ref device.MultiPacketGroupsExecuted);
await ReadMultiPacketBatchAsync(mpBatch, results, now, ct).ConfigureAwait(false);
routedParents.Add(parentName);
continue;
}
}
}
}
await ReadSingleAsync(fb, fullReferences[fb.OriginalIndex], results, now, ct).ConfigureAwait(false);
}
}
/// <summary>
/// PR abcip-3.3 — pick the effective <see cref="ReadStrategy"/> for one parent UDT group.
/// <see cref="ReadStrategy.WholeUdt"/> + <see cref="ReadStrategy.MultiPacket"/> are
/// forced explicitly (already family-compat-checked at device init). <see cref="ReadStrategy.Auto"/>
/// consults the planner heuristic on subscribed-member fraction, but only when the
/// family supports request packing — non-packing families always read WholeUdt regardless
/// of sparsity because they have no Multi-Service-Packet path on the wire.
/// </summary>
private static ReadStrategy ChooseEffectiveStrategy(DeviceState? device, AbCipUdtReadGroup group)
{
if (device is null) return ReadStrategy.WholeUdt;
switch (device.ReadStrategy)
{
case ReadStrategy.MultiPacket:
return ReadStrategy.MultiPacket;
case ReadStrategy.WholeUdt:
return ReadStrategy.WholeUdt;
case ReadStrategy.Auto:
default:
if (!device.Profile.SupportsRequestPacking) return ReadStrategy.WholeUdt;
var totalMembers = group.ParentDefinition.Members?.Count ?? 0;
return AbCipMultiPacketReadPlanner.ChooseStrategyForGroup(
subscribedMembers: group.Members.Count,
totalMembers: totalMembers,
threshold: device.Options.MultiPacketSparsityThreshold);
}
}
/// <summary>
/// PR abcip-3.3 — strategy pick for a singleton (one-member) UDT batch. Only relevant
/// when the device strategy is explicit <see cref="ReadStrategy.MultiPacket"/> or Auto
/// produces a MultiPacket result; otherwise the per-tag fallback path runs as before.
/// </summary>
private static ReadStrategy ChooseEffectiveStrategyForSingleton(
DeviceState device, AbCipMultiPacketReadBatch batch)
{
switch (device.ReadStrategy)
{
case ReadStrategy.MultiPacket:
return ReadStrategy.MultiPacket;
case ReadStrategy.WholeUdt:
return ReadStrategy.WholeUdt;
case ReadStrategy.Auto:
default:
if (!device.Profile.SupportsRequestPacking) return ReadStrategy.WholeUdt;
var totalMembers = batch.ParentDefinition.Members?.Count ?? 0;
return AbCipMultiPacketReadPlanner.ChooseStrategyForGroup(
subscribedMembers: batch.Members.Count,
totalMembers: totalMembers,
threshold: device.Options.MultiPacketSparsityThreshold);
}
}
/// <summary>
/// PR abcip-3.3 — execute a Multi-Service-Packet batch. Today this issues one libplctag
/// read per member (the same N reads the per-tag fallback path does), keyed on the
/// batch's parent so the diagnostic counters track which strategy ran. Wire-level
/// Multi-Service-Packet bundling depends on the libplctag .NET wrapper exposing the
/// 0x0A service explicitly — same wrapper limitation as PR abcip-3.1's connection_size
/// and PR abcip-3.2's instance-ID addressing. The planner's grouping is still
/// load-bearing because it gives the runtime the correct plan when an upstream wrapper
/// release exposes the bundling primitive.
/// </summary>
private async Task ReadMultiPacketBatchAsync(
AbCipMultiPacketReadBatch batch, DataValueSnapshot[] results, DateTime now, CancellationToken ct)
{
var parent = batch.ParentDefinition;
if (!_devices.TryGetValue(parent.DeviceHostAddress, out var device))
{
foreach (var m in batch.Members)
results[m.OriginalIndex] = new DataValueSnapshot(null, AbCipStatusMapper.BadNodeIdUnknown, null, now);
return;
}
foreach (var member in batch.Members)
{
var memberFullName = member.Definition.Name;
var fb = new AbCipUdtReadFallback(member.OriginalIndex, memberFullName);
await ReadSingleAsync(fb, memberFullName, results, now, ct).ConfigureAwait(false);
}
}
/// <summary>
/// PR abcip-3.2 — for each Logical-mode device touched by this read batch, fire the
/// one-time <c>@tags</c> symbol-table walk + populate <see cref="DeviceState.LogicalInstanceMap"/>.
@@ -1366,7 +1582,8 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
AbCipHostAddress parsedAddress,
AbCipDeviceOptions options,
AbCipPlcFamilyProfile profile,
AddressingMode resolvedAddressingMode)
AddressingMode resolvedAddressingMode,
ReadStrategy resolvedReadStrategy)
{
public AbCipHostAddress ParsedAddress { get; } = parsedAddress;
public AbCipDeviceOptions Options { get; } = options;
@@ -1391,6 +1608,25 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
/// </summary>
public AddressingMode AddressingMode { get; } = resolvedAddressingMode;
/// <summary>
/// PR abcip-3.3 — resolved read strategy for this device. <see cref="AbCip.ReadStrategy.WholeUdt"/>
/// or <see cref="AbCip.ReadStrategy.MultiPacket"/> mean "always pick this for every UDT
/// batch on this device." <see cref="AbCip.ReadStrategy.Auto"/> means "let the planner
/// pick per-batch using <see cref="AbCipDeviceOptions.MultiPacketSparsityThreshold"/>."
/// User-forced MultiPacket against a non-packing family (Micro800 et al) was already
/// collapsed to WholeUdt at <see cref="AbCipDriver.ResolveReadStrategy"/> time, so the
/// read hot path can branch on this single value without re-checking family compat.
/// </summary>
public ReadStrategy ReadStrategy { get; } = resolvedReadStrategy;
/// <summary>PR abcip-3.3 — count of UDT groups dispatched through the WholeUdt path on
/// this device. Surfaced for tests + a future driver-diagnostics RPC.</summary>
public int WholeUdtGroupsExecuted;
/// <summary>PR abcip-3.3 — count of UDT groups dispatched through the MultiPacket path
/// on this device. Surfaced for tests + a future driver-diagnostics RPC.</summary>
public int MultiPacketGroupsExecuted;
/// <summary>
/// PR abcip-3.2 — name → Symbol Object instance ID map populated by the one-time
/// <c>@tags</c> walk that fires on the first read on a Logical-mode device. Empty

View File

@@ -40,7 +40,10 @@ public static class AbCipDriverFactoryExtensions
DeviceName: d.DeviceName,
ConnectionSize: d.ConnectionSize,
AddressingMode: ParseEnum<AddressingMode>(d.AddressingMode, "device", driverInstanceId,
"AddressingMode", fallback: AddressingMode.Auto)))]
"AddressingMode", fallback: AddressingMode.Auto),
ReadStrategy: ParseEnum<ReadStrategy>(d.ReadStrategy, "device", driverInstanceId,
"ReadStrategy", fallback: ReadStrategy.Auto),
MultiPacketSparsityThreshold: d.MultiPacketSparsityThreshold ?? 0.25))]
: [],
Tags = dto.Tags is { Count: > 0 }
? [.. dto.Tags.Select(t => BuildTag(t, driverInstanceId))]
@@ -137,6 +140,23 @@ public static class AbCipDriverFactoryExtensions
/// Micro800 / SLC500 / PLC5 falls back to Symbolic with a warning.
/// </summary>
public string? AddressingMode { get; init; }
/// <summary>
/// PR abcip-3.3 — optional per-device read-strategy override. <c>"Auto"</c>,
/// <c>"WholeUdt"</c>, or <c>"MultiPacket"</c>. Defaults to <c>Auto</c> (the planner
/// picks per-batch using <see cref="MultiPacketSparsityThreshold"/>). Family
/// compatibility is enforced at <see cref="AbCipDriver.InitializeAsync"/>: explicit
/// <c>MultiPacket</c> against Micro800 (no
/// <see cref="PlcFamilies.AbCipPlcFamilyProfile.SupportsRequestPacking"/>) falls
/// back to <c>WholeUdt</c> with a warning.
/// </summary>
public string? ReadStrategy { get; init; }
/// <summary>
/// PR abcip-3.3 — sparsity-threshold knob applied when <see cref="ReadStrategy"/>
/// resolves to <c>Auto</c>. Default <c>0.25</c>; clamped to <c>[0..1]</c>.
/// </summary>
public double? MultiPacketSparsityThreshold { get; init; }
}
internal sealed class AbCipTagDto

View File

@@ -124,12 +124,83 @@ public sealed class AbCipDriverOptions
/// misconfiguration does not fault the driver. <see cref="AbCip.AddressingMode.Auto"/> currently
/// resolves to symbolic — a future PR will plumb a real auto-detection heuristic; the docs
/// in <c>docs/drivers/AbCip-Performance.md</c> §"Addressing mode" call this out.</param>
/// <param name="ReadStrategy">PR abcip-3.3 — picks how a multi-member UDT batch is read on this
/// device. <see cref="AbCip.ReadStrategy.WholeUdt"/> issues one read per parent UDT and decodes
/// each subscribed member from the buffer in-memory (the historical behaviour that ships in
/// task #194 — best when a large fraction of a UDT's members are subscribed).
/// <see cref="AbCip.ReadStrategy.MultiPacket"/> bundles per-member reads into one CIP
/// Multi-Service Packet — best for sparse UDT subscriptions where reading the whole UDT
/// buffer just to extract one or two fields wastes wire bandwidth. <see cref="AbCip.ReadStrategy.Auto"/>
/// (the default) lets the planner pick per-batch using
/// <paramref name="MultiPacketSparsityThreshold"/>: if the subscribed-member fraction is below
/// the threshold MultiPacket wins, otherwise WholeUdt wins. Family compatibility — Micro800 /
/// SLC500 / PLC5 lack Multi-Service-Packet support per
/// <see cref="PlcFamilies.AbCipPlcFamilyProfile.SupportsRequestPacking"/>; user-forced
/// <see cref="AbCip.ReadStrategy.MultiPacket"/> against those families logs a warning + falls
/// back to <see cref="AbCip.ReadStrategy.WholeUdt"/> at device-init time. The libplctag .NET
/// wrapper (1.5.x) does not expose a public knob for explicit Multi-Service-Packet bundling,
/// so today's MultiPacket runtime issues one libplctag read per member; the planner's grouping
/// is still load-bearing because it gives the runtime the right plan to execute when an
/// upstream wrapper release exposes wire-level bundling.</param>
/// <param name="MultiPacketSparsityThreshold">PR abcip-3.3 — sparsity-threshold knob the planner
/// uses when <paramref name="ReadStrategy"/> is <see cref="AbCip.ReadStrategy.Auto"/>. The
/// planner divides <c>subscribedMembers / totalMembers</c> for each parent UDT in a batch;
/// a fraction strictly less than the threshold picks
/// <see cref="AbCip.ReadStrategy.MultiPacket"/>, else <see cref="AbCip.ReadStrategy.WholeUdt"/>.
/// Default <c>0.25</c> — picked because reading 1/4 of a UDT's members is the rough break-even
/// where the wire-cost of one whole-UDT read still beats N member reads on ControlLogix's
/// 4002-byte connection size; see <c>docs/drivers/AbCip-Performance.md</c> §"Read strategy".
/// Clamped to <c>[0..1]</c> at planner time; values outside the range silently saturate.</param>
public sealed record AbCipDeviceOptions(
string HostAddress,
AbCipPlcFamily PlcFamily = AbCipPlcFamily.ControlLogix,
string? DeviceName = null,
int? ConnectionSize = null,
AddressingMode AddressingMode = AddressingMode.Auto);
AddressingMode AddressingMode = AddressingMode.Auto,
ReadStrategy ReadStrategy = ReadStrategy.Auto,
double MultiPacketSparsityThreshold = 0.25);
/// <summary>
/// PR abcip-3.3 — per-device strategy for reading multi-member UDT batches. <see cref="WholeUdt"/>
/// mirrors the task #194 behaviour: one libplctag read on the parent tag, each subscribed member
/// decoded from the buffer at its computed offset. <see cref="MultiPacket"/> bundles per-member
/// reads into one CIP Multi-Service Packet so sparse UDT subscriptions don't pay for the whole
/// UDT buffer. <see cref="Auto"/> lets the planner pick per-batch using
/// <see cref="AbCipDeviceOptions.MultiPacketSparsityThreshold"/>.
/// </summary>
/// <remarks>
/// <para>Strategy resolution lives at two layers:</para>
/// <list type="bullet">
/// <item><b>Device init</b> — user-forced <see cref="MultiPacket"/> against a family whose
/// profile sets <see cref="PlcFamilies.AbCipPlcFamilyProfile.SupportsRequestPacking"/>
/// = <c>false</c> (Micro800, SLC500, PLC5) falls back to <see cref="WholeUdt"/> with a
/// warning. <see cref="Auto"/> stays as-is (the planner re-evaluates per batch).</item>
/// <item><b>Per-batch (Auto only)</b> — for each parent UDT in the request set, the planner
/// computes <c>subscribedMembers / totalMembers</c> and routes the group through
/// <see cref="MultiPacket"/> when the fraction is below the threshold, else
/// <see cref="WholeUdt"/>.</item>
/// </list>
/// <para>libplctag .NET wrapper (1.5.x) does not expose explicit Multi-Service-Packet bundling,
/// so today's runtime issues one libplctag read per member when the planner picks MultiPacket —
/// the same wrapper limitation called out in PR abcip-3.1 (ConnectionSize) and PR abcip-3.2
/// (instance-ID addressing). The planner's grouping is still observable from tests + future-proofs
/// the driver for when an upstream wrapper release exposes wire-level bundling.</para>
/// </remarks>
public enum ReadStrategy
{
/// <summary>Driver picks per-batch based on
/// <see cref="AbCipDeviceOptions.MultiPacketSparsityThreshold"/>. Default.</summary>
Auto = 0,
/// <summary>One read per parent UDT; members decoded from the buffer in-memory. Best when a
/// large fraction of the UDT's members are subscribed (dense reads).</summary>
WholeUdt = 1,
/// <summary>Bundle per-member reads into one CIP Multi-Service Packet. Best when only a few
/// members of a large UDT are subscribed (sparse reads). Unsupported on Micro800 / SLC500 /
/// PLC5; the driver warns + falls back to <see cref="WholeUdt"/> at device init.</summary>
MultiPacket = 2,
}
/// <summary>
/// PR abcip-3.2 — how the AB CIP driver addresses tags on a given device. <see cref="Symbolic"/>

View File

@@ -0,0 +1,132 @@
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);

View File

@@ -26,6 +26,7 @@
<ItemGroup>
<InternalsVisibleTo Include="ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests"/>
<InternalsVisibleTo Include="ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests"/>
</ItemGroup>
</Project>