diff --git a/docs/drivers/AbCip-Performance.md b/docs/drivers/AbCip-Performance.md
index fdf2823..45f1c02 100644
--- a/docs/drivers/AbCip-Performance.md
+++ b/docs/drivers/AbCip-Performance.md
@@ -306,3 +306,100 @@ the field defaults to `"Auto"`.
"What it actually covers" — Logical-mode fixture coverage status.
- [`AbCipAddressingModeBenchTests`](../../tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/AbCipAddressingModeBenchTests.cs) —
scaffold for the wall-clock comparison; gated on `[AbServerFact]`.
+
+## Read strategy (PR abcip-3.3)
+
+A per-device toggle that controls how multi-member UDT batches are read.
+The default `Auto` value matches every previous build's behaviour for dense
+reads but switches to per-member bundling when only a handful of members of
+a large UDT are subscribed — the canonical "5 of 50" sparse-subscription
+case where reading the whole UDT buffer just to extract a few fields wastes
+wire bandwidth.
+
+### Three modes
+
+| Mode | When to use |
+|---|---|
+| `WholeUdt` | Most members of every subscribed UDT are read together. One libplctag read per parent UDT, members decoded in-memory at their byte offsets. The task #194 default. |
+| `MultiPacket` | A few members of a large UDT are subscribed at a time. One read per subscribed member, bundled per parent into one CIP Multi-Service Packet. |
+| `Auto` (default) | Planner picks per-batch from the subscribed-member fraction (see *Sparsity threshold*). |
+
+### Sparsity threshold
+
+Auto mode divides `subscribedMembers / totalMembers` for each parent UDT and
+picks `MultiPacket` when the fraction is **strictly less than** the
+threshold, else `WholeUdt`. Default threshold `0.25` — a 1/4 subscription is
+the rough break-even where the wire-cost of one whole-UDT read still beats
+N member reads on a ControlLogix 4002-byte connection-size buffer; above
+1/4, the per-member overhead dominates.
+
+Tune via `AbCipDeviceOptions.MultiPacketSparsityThreshold` (clamped to
+`[0..1]`). Threshold `0.0` = "never MultiPacket"; `1.0` = "always MultiPacket
+when any member is subscribed."
+
+### Family compatibility
+
+`MultiPacket` requires CIP service `0x0A` (Multi-Service Packet) on the
+controller. Source of truth is
+[`AbCipPlcFamilyProfile.SupportsRequestPacking`](../../src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/PlcFamilies/AbCipPlcFamilyProfile.cs):
+
+| Family | `SupportsRequestPacking` |
+|---|---|
+| ControlLogix | yes |
+| CompactLogix | yes |
+| GuardLogix | yes (wire identical to ControlLogix) |
+| Micro800 | **no** |
+| SLC500 / PLC5 (when those profiles ship) | **no** |
+
+User-forced `MultiPacket` against a non-packing family logs a warning at
+device init and falls back to `WholeUdt`. `Auto` against a non-packing
+family stays `Auto` at the device level — the per-batch heuristic caps the
+strategy to `WholeUdt` so the wire never sees a Multi-Service-Packet against
+a controller that can't decode it.
+
+### libplctag wrapper limitation
+
+The libplctag .NET wrapper (1.5.x) does not expose the `0x0A` service as a
+public knob — same wrapper-version constraint that gates PR abcip-3.1's
+`connection_size` and PR abcip-3.2's instance-ID addressing. Today's
+MultiPacket runtime therefore issues N libplctag reads sequentially when
+the planner picks the strategy; the wire-level bundling lands cleanly when
+an upstream wrapper release exposes the primitive.
+
+The driver-level bookkeeping (resolved strategy, per-batch heuristic,
+family-compat fall-back, per-device dispatch counters) is fully wired so
+the upgrade path is a wrapper-version bump only — the planner already
+produces the right plan, and `AbCipMultiPacketReadPlanner.Build` is
+covered by unit tests that pin the plan shape rather than wire bytes.
+
+### Driver config JSON
+
+```json
+{
+ "Devices": [
+ {
+ "HostAddress": "ab://10.0.0.5/1,0",
+ "PlcFamily": "ControlLogix",
+ "ReadStrategy": "Auto",
+ "MultiPacketSparsityThreshold": 0.25
+ }
+ ]
+}
+```
+
+`"Auto"`, `"WholeUdt"`, and `"MultiPacket"` parse case-insensitively.
+Omitting the field defaults to `"Auto"`. Omitting
+`MultiPacketSparsityThreshold` defaults to `0.25`.
+
+### See also
+
+- [`AbCipDriverOptions.ReadStrategy`](../../src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriverOptions.cs) —
+ enum definition + per-value docstrings.
+- [`AbCipMultiPacketReadPlanner`](../../src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipMultiPacketReadPlanner.cs) —
+ plan shape + Auto-mode heuristic.
+- [`AbCipPlcFamilyProfile.SupportsRequestPacking`](../../src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/PlcFamilies/AbCipPlcFamilyProfile.cs) —
+ family compatibility table source-of-truth.
+- [`AbCipReadStrategyTests`](../../tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipReadStrategyTests.cs) —
+ device-init resolution, heuristic edges, dispatch counters, DTO round-trip.
+- [`AbCipEmulateMultiPacketReadTests`](../../tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/Emulate/AbCipEmulateMultiPacketReadTests.cs) —
+ golden-box-tier wire-level coverage scaffold; gated on `AB_SERVER_PROFILE=emulate`.
diff --git a/docs/drivers/AbServer-Test-Fixture.md b/docs/drivers/AbServer-Test-Fixture.md
index 1eac0f3..32fa7e6 100644
--- a/docs/drivers/AbServer-Test-Fixture.md
+++ b/docs/drivers/AbServer-Test-Fixture.md
@@ -70,6 +70,19 @@ Unit coverage: `AbCipFetchUdtShapeTests`, `CipTemplateObjectDecoderTests`,
`AbCipDriverWholeUdtReadTests` — all with golden Template-Object byte buffers
+ offset-keyed `FakeAbCipTag` values.
+PR abcip-3.3 layers a per-device **`ReadStrategy`** selector on top
+(`WholeUdt` / `MultiPacket` / `Auto`, see
+[`AbCip-Performance.md`](AbCip-Performance.md) §"Read strategy"). Strategy
+switching is planner-side: the dispatcher picks between
+`AbCipUdtReadPlanner` (whole-UDT) and `AbCipMultiPacketReadPlanner`
+(per-member, bundled per parent) per batch. The selector + per-batch Auto
+heuristic + family-compat fall-back + per-device dispatch counters are
+**unit-tested only** in `AbCipReadStrategyTests` — `ab_server` cannot host
+a 50-member UDT to exercise the sparse case the strategy is designed for,
+and the libplctag .NET wrapper (1.5.x) does not expose explicit
+Multi-Service-Packet bundling, so wire-level coverage stays Emulate-tier
+in `AbCipEmulateMultiPacketReadTests` (gated on `AB_SERVER_PROFILE=emulate`).
+
### 2. ALMD / ALMA alarm projection (#177)
Depends on the ALMD UDT shape, which `ab_server` cannot emulate. The
diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs
index a7aacec..15404ca 100644
--- a/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs
+++ b/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs
@@ -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,
}
}
+ ///
+ /// PR abcip-3.3 — resolve against the
+ /// family profile. against a family whose profile
+ /// sets = false
+ /// (Micro800 today; SLC500 / PLC5 when those profiles ship) falls back to
+ /// with a warning so the operator sees the
+ /// misconfiguration in the log without the driver faulting.
+ /// stays as-is — the planner re-evaluates the choice per-batch from the device's
+ /// ; 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.
+ ///
+ 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;
+ }
+ }
+
///
/// 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;
}
+ ///
+ /// 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 ; only the group shape
+ /// differs. Auto resolves per-group: members of the same parent UDT either flow through
+ /// (one whole-UDT read) or
+ /// (per-member reads bundled per parent).
+ ///
+ private async Task ExecuteReadPlanAsync(
+ IReadOnlyList 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(
+ 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(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);
+ }
+ }
+
+ ///
+ /// PR abcip-3.3 — pick the effective for one parent UDT group.
+ /// + are
+ /// forced explicitly (already family-compat-checked at device init).
+ /// 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.
+ ///
+ 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);
+ }
+ }
+
+ ///
+ /// PR abcip-3.3 — strategy pick for a singleton (one-member) UDT batch. Only relevant
+ /// when the device strategy is explicit or Auto
+ /// produces a MultiPacket result; otherwise the per-tag fallback path runs as before.
+ ///
+ 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);
+ }
+ }
+
+ ///
+ /// 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.
+ ///
+ 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);
+ }
+ }
+
///
/// PR abcip-3.2 — for each Logical-mode device touched by this read batch, fire the
/// one-time @tags symbol-table walk + populate .
@@ -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,
///
public AddressingMode AddressingMode { get; } = resolvedAddressingMode;
+ ///
+ /// PR abcip-3.3 — resolved read strategy for this device.
+ /// or mean "always pick this for every UDT
+ /// batch on this device." means "let the planner
+ /// pick per-batch using ."
+ /// User-forced MultiPacket against a non-packing family (Micro800 et al) was already
+ /// collapsed to WholeUdt at time, so the
+ /// read hot path can branch on this single value without re-checking family compat.
+ ///
+ public ReadStrategy ReadStrategy { get; } = resolvedReadStrategy;
+
+ /// PR abcip-3.3 — count of UDT groups dispatched through the WholeUdt path on
+ /// this device. Surfaced for tests + a future driver-diagnostics RPC.
+ public int WholeUdtGroupsExecuted;
+
+ /// PR abcip-3.3 — count of UDT groups dispatched through the MultiPacket path
+ /// on this device. Surfaced for tests + a future driver-diagnostics RPC.
+ public int MultiPacketGroupsExecuted;
+
///
/// PR abcip-3.2 — name → Symbol Object instance ID map populated by the one-time
/// @tags walk that fires on the first read on a Logical-mode device. Empty
diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriverFactoryExtensions.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriverFactoryExtensions.cs
index 51f82f0..9e36e4b 100644
--- a/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriverFactoryExtensions.cs
+++ b/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriverFactoryExtensions.cs
@@ -40,7 +40,10 @@ public static class AbCipDriverFactoryExtensions
DeviceName: d.DeviceName,
ConnectionSize: d.ConnectionSize,
AddressingMode: ParseEnum(d.AddressingMode, "device", driverInstanceId,
- "AddressingMode", fallback: AddressingMode.Auto)))]
+ "AddressingMode", fallback: AddressingMode.Auto),
+ ReadStrategy: ParseEnum(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.
///
public string? AddressingMode { get; init; }
+
+ ///
+ /// PR abcip-3.3 — optional per-device read-strategy override. "Auto",
+ /// "WholeUdt", or "MultiPacket". Defaults to Auto (the planner
+ /// picks per-batch using ). Family
+ /// compatibility is enforced at : explicit
+ /// MultiPacket against Micro800 (no
+ /// ) falls
+ /// back to WholeUdt with a warning.
+ ///
+ public string? ReadStrategy { get; init; }
+
+ ///
+ /// PR abcip-3.3 — sparsity-threshold knob applied when
+ /// resolves to Auto. Default 0.25; clamped to [0..1].
+ ///
+ public double? MultiPacketSparsityThreshold { get; init; }
}
internal sealed class AbCipTagDto
diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriverOptions.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriverOptions.cs
index 06ccc3e..27c8956 100644
--- a/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriverOptions.cs
+++ b/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriverOptions.cs
@@ -124,12 +124,83 @@ public sealed class AbCipDriverOptions
/// misconfiguration does not fault the driver. currently
/// resolves to symbolic — a future PR will plumb a real auto-detection heuristic; the docs
/// in docs/drivers/AbCip-Performance.md §"Addressing mode" call this out.
+/// PR abcip-3.3 — picks how a multi-member UDT batch is read on this
+/// device. 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).
+/// 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.
+/// (the default) lets the planner pick per-batch using
+/// : 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
+/// ; user-forced
+/// against those families logs a warning + falls
+/// back to 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.
+/// PR abcip-3.3 — sparsity-threshold knob the planner
+/// uses when is . The
+/// planner divides subscribedMembers / totalMembers for each parent UDT in a batch;
+/// a fraction strictly less than the threshold picks
+/// , else .
+/// Default 0.25 — 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 docs/drivers/AbCip-Performance.md §"Read strategy".
+/// Clamped to [0..1] at planner time; values outside the range silently saturate.
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);
+
+///
+/// PR abcip-3.3 — per-device strategy for reading multi-member UDT batches.
+/// mirrors the task #194 behaviour: one libplctag read on the parent tag, each subscribed member
+/// decoded from the buffer at its computed offset. bundles per-member
+/// reads into one CIP Multi-Service Packet so sparse UDT subscriptions don't pay for the whole
+/// UDT buffer. lets the planner pick per-batch using
+/// .
+///
+///
+/// Strategy resolution lives at two layers:
+///
+/// - Device init — user-forced against a family whose
+/// profile sets
+/// = false (Micro800, SLC500, PLC5) falls back to with a
+/// warning. stays as-is (the planner re-evaluates per batch).
+/// - Per-batch (Auto only) — for each parent UDT in the request set, the planner
+/// computes subscribedMembers / totalMembers and routes the group through
+/// when the fraction is below the threshold, else
+/// .
+///
+/// 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.
+///
+public enum ReadStrategy
+{
+ /// Driver picks per-batch based on
+ /// . Default.
+ Auto = 0,
+
+ /// 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).
+ WholeUdt = 1,
+
+ /// 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 at device init.
+ MultiPacket = 2,
+}
///
/// PR abcip-3.2 — how the AB CIP driver addresses tags on a given device.
diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipMultiPacketReadPlanner.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipMultiPacketReadPlanner.cs
new file mode 100644
index 0000000..af1960f
--- /dev/null
+++ b/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipMultiPacketReadPlanner.cs
@@ -0,0 +1,132 @@
+namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
+
+///
+/// PR abcip-3.3 — sparse-UDT read planner. Where 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.
+///
+///
+/// Pure function — like its sibling planner, this one never touches the runtime + never
+/// reads the PLC. It produces the plan; executes it.
+///
+/// The planner is intentionally libplctag-agnostic: the output is just a list of
+/// 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
+/// AbCipMultiPacketReadPlannerTests assert plan shape rather than wire bytes.
+///
+/// Auto-mode dispatch (the heuristic): callers run
+/// for each parent UDT to pick between the WholeUdt and MultiPacket paths per-group. The
+/// heuristic divides subscribedMembers / totalMembers and picks MultiPacket when the
+/// fraction is strictly less than the device's
+/// .
+///
+public static class AbCipMultiPacketReadPlanner
+{
+ ///
+ /// Build a multi-packet read plan from . Members of the same
+ /// parent UDT collapse into one ; references that
+ /// don't resolve to a UDT member fall back to for the
+ /// existing per-tag read path.
+ ///
+ public static AbCipMultiPacketReadPlan Build(
+ IReadOnlyList requests,
+ IReadOnlyDictionary tagsByName)
+ {
+ ArgumentNullException.ThrowIfNull(requests);
+ ArgumentNullException.ThrowIfNull(tagsByName);
+
+ var fallback = new List(requests.Count);
+ var byParent = new Dictionary>(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();
+ byParent[parentName] = members;
+ }
+ members.Add(new AbCipUdtReadMember(i, def, offset));
+ }
+
+ var batches = new List(byParent.Count);
+ foreach (var (parentName, members) in byParent)
+ {
+ batches.Add(new AbCipMultiPacketReadBatch(parentName, tagsByName[parentName], members));
+ }
+
+ return new AbCipMultiPacketReadPlan(batches, fallback);
+ }
+
+ ///
+ /// PR abcip-3.3 — Auto-mode heuristic. For a single parent UDT group with
+ /// of declared
+ /// members, pick when sparsity is strictly below
+ /// , else . Threshold is
+ /// clamped to [0..1]; out-of-range values saturate. Edge cases:
+ /// totalMembers == 0 defaults to (the
+ /// historical behaviour) so a misconfigured tag map doesn't fault the read.
+ ///
+ 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)..]);
+ }
+}
+
+/// A planner output: per-parent multi-packet batches + per-tag fallbacks.
+public sealed record AbCipMultiPacketReadPlan(
+ IReadOnlyList Batches,
+ IReadOnlyList Fallbacks);
+
+///
+/// One UDT parent whose subscribed members are bundled into a Multi-Service Packet read.
+/// Reuses from the WholeUdt planner so callers can decode
+/// the member offsets uniformly across both planners.
+///
+public sealed record AbCipMultiPacketReadBatch(
+ string ParentName,
+ AbCipTagDefinition ParentDefinition,
+ IReadOnlyList Members);
diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/ZB.MOM.WW.OtOpcUa.Driver.AbCip.csproj b/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/ZB.MOM.WW.OtOpcUa.Driver.AbCip.csproj
index e773960..9b0baf8 100644
--- a/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/ZB.MOM.WW.OtOpcUa.Driver.AbCip.csproj
+++ b/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/ZB.MOM.WW.OtOpcUa.Driver.AbCip.csproj
@@ -26,6 +26,7 @@
+
diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/Emulate/AbCipEmulateMultiPacketReadTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/Emulate/AbCipEmulateMultiPacketReadTests.cs
new file mode 100644
index 0000000..f134b06
--- /dev/null
+++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/Emulate/AbCipEmulateMultiPacketReadTests.cs
@@ -0,0 +1,100 @@
+using Shouldly;
+using Xunit;
+using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
+using ZB.MOM.WW.OtOpcUa.Driver.AbCip;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Emulate;
+
+///
+/// PR abcip-3.3 — golden-box-tier MultiPacket read-strategy test against Logix Emulate.
+/// Exercises the sparse-UDT case the strategy is designed for: a 50-member UDT instance
+/// where the OPC UA client subscribed to 5 members. Asserts the driver routes the read
+/// through the MultiPacket planner (
+/// counter increments) and returns Good StatusCodes for every member.
+///
+///
+/// Required Emulate project state (see LogixProject/README.md for
+/// the L5X export that seeds this; ship the project once Emulate is on the integration
+/// host):
+///
+/// - UDT Tank_50 with 50 DINT members M0..M49 — a deliberately
+/// oversized UDT so a 5-member subscription is sparse enough for the
+/// default of 0.25 to
+/// pick MultiPacket.
+/// - Controller-scope tag Tank1 : Tank_50 with each M{i} seeded to
+/// i * 10 so each subscribed member returns a distinct value.
+///
+/// Runs only when AB_SERVER_PROFILE=emulate. With the default ab_server the
+/// test skips cleanly — ab_server lacks UDT / Multi-Service-Packet emulation depth so a
+/// wire-level pass against it would be vacuous regardless. Note: the libplctag .NET
+/// wrapper (1.5.x) does not expose explicit Multi-Service-Packet bundling, so the
+/// driver's MultiPacket runtime today issues N member reads sequentially. The planner-tier
+/// dispatch is what's under test here — the wire-level bundling lands when the upstream
+/// wrapper exposes the 0x0A service primitive (see
+/// docs/drivers/AbCip-Performance.md §"Read strategy").
+///
+[Collection("AbServerEmulate")]
+[Trait("Category", "Integration")]
+[Trait("Tier", "Emulate")]
+public sealed class AbCipEmulateMultiPacketReadTests
+{
+ [AbServerFact]
+ public async Task Sparse_5_of_50_member_subscription_dispatches_through_MultiPacket()
+ {
+ AbServerProfileGate.SkipUnless(AbServerProfileGate.Emulate);
+
+ var endpoint = Environment.GetEnvironmentVariable("AB_SERVER_ENDPOINT")
+ ?? throw new InvalidOperationException(
+ "AB_SERVER_ENDPOINT must be set to the Logix Emulate instance " +
+ "(e.g. '10.0.0.42:44818') when AB_SERVER_PROFILE=emulate.");
+
+ // Build a 50-member declared UDT — the planner needs the full member set to compute
+ // the subscribed-fraction in the Auto heuristic and to place MultiPacket member offsets.
+ var members = new AbCipStructureMember[50];
+ for (var i = 0; i < 50; i++)
+ members[i] = new AbCipStructureMember($"M{i}", AbCipDataType.DInt);
+
+ var options = new AbCipDriverOptions
+ {
+ Devices = [new AbCipDeviceOptions(
+ HostAddress: $"ab://{endpoint}/1,0",
+ PlcFamily: AbCipPlcFamily.ControlLogix,
+ ReadStrategy: ReadStrategy.MultiPacket)],
+ Tags = [
+ new AbCipTagDefinition(
+ Name: "Tank1",
+ DeviceHostAddress: $"ab://{endpoint}/1,0",
+ TagPath: "Tank1",
+ DataType: AbCipDataType.Structure,
+ Members: members),
+ ],
+ Timeout = TimeSpan.FromSeconds(5),
+ };
+
+ await using var drv = new AbCipDriver(options, driverInstanceId: "emulate-multipacket-smoke");
+ await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
+
+ // Sparse pick: 5 of 50 = 0.10 < default threshold 0.25 → MultiPacket planner. Force
+ // the strategy explicitly above so the test isn't sensitive to threshold drift.
+ var refs = new[] { "Tank1.M0", "Tank1.M3", "Tank1.M7", "Tank1.M22", "Tank1.M49" };
+ var snapshots = await drv.ReadAsync(refs, TestContext.Current.CancellationToken);
+
+ snapshots.Count.ShouldBe(5);
+ foreach (var s in snapshots) s.StatusCode.ShouldBe(AbCipStatusMapper.Good);
+
+ // Plan-stats counter assertion — the device-level counter increments once per parent
+ // UDT routed through the MultiPacket path. Sibling counter for WholeUdt must stay zero.
+ var deviceState = drv.GetDeviceState($"ab://{endpoint}/1,0");
+ deviceState.ShouldNotBeNull();
+ deviceState!.MultiPacketGroupsExecuted.ShouldBeGreaterThan(0);
+ deviceState.WholeUdtGroupsExecuted.ShouldBe(0);
+
+ // Sanity-check the seeded values land at the right indices: M{i} == i * 10 in the
+ // emulate fixture's startup routine.
+ Convert.ToInt32(snapshots[0].Value).ShouldBe(0);
+ Convert.ToInt32(snapshots[1].Value).ShouldBe(30);
+ Convert.ToInt32(snapshots[2].Value).ShouldBe(70);
+ Convert.ToInt32(snapshots[3].Value).ShouldBe(220);
+ Convert.ToInt32(snapshots[4].Value).ShouldBe(490);
+ }
+}
diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipReadStrategyTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipReadStrategyTests.cs
new file mode 100644
index 0000000..3e26b3d
--- /dev/null
+++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipReadStrategyTests.cs
@@ -0,0 +1,412 @@
+using Shouldly;
+using Xunit;
+using ZB.MOM.WW.OtOpcUa.Driver.AbCip;
+using ZB.MOM.WW.OtOpcUa.Driver.AbCip.PlcFamilies;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
+
+///
+/// PR abcip-3.3 — coverage for the per-device selector. Three
+/// resolution layers under test: (a) at
+/// device init (MultiPacket-against-Micro800 fall-back, plain pass-through otherwise),
+/// (b) sparsity heuristic
+/// (Auto-mode dispatch), (c) end-to-end dispatch
+/// verified by the per-device WholeUdt / MultiPacket counters.
+///
+[Trait("Category", "Unit")]
+public sealed class AbCipReadStrategyTests
+{
+ private const string Device = "ab://10.0.0.5/1,0";
+ private const string Micro = "ab://10.0.0.6/";
+
+ // ---- Device init resolution ----
+
+ [Fact]
+ public async Task Default_ReadStrategy_resolves_to_Auto_on_DeviceState()
+ {
+ var drv = new AbCipDriver(new AbCipDriverOptions
+ {
+ Devices = [new AbCipDeviceOptions(Device, AbCipPlcFamily.ControlLogix)],
+ Probe = new AbCipProbeOptions { Enabled = false },
+ }, "drv-1");
+ await drv.InitializeAsync("{}", CancellationToken.None);
+
+ drv.GetDeviceState(Device)!.ReadStrategy.ShouldBe(ReadStrategy.Auto);
+ }
+
+ [Fact]
+ public async Task User_forced_WholeUdt_passes_through_init()
+ {
+ var drv = new AbCipDriver(new AbCipDriverOptions
+ {
+ Devices = [new AbCipDeviceOptions(Device, AbCipPlcFamily.ControlLogix,
+ ReadStrategy: ReadStrategy.WholeUdt)],
+ Probe = new AbCipProbeOptions { Enabled = false },
+ }, "drv-1");
+ await drv.InitializeAsync("{}", CancellationToken.None);
+
+ drv.GetDeviceState(Device)!.ReadStrategy.ShouldBe(ReadStrategy.WholeUdt);
+ }
+
+ [Fact]
+ public async Task User_forced_MultiPacket_on_ControlLogix_passes_through_init()
+ {
+ var warnings = new List();
+ var drv = new AbCipDriver(new AbCipDriverOptions
+ {
+ Devices = [new AbCipDeviceOptions(Device, AbCipPlcFamily.ControlLogix,
+ ReadStrategy: ReadStrategy.MultiPacket)],
+ Probe = new AbCipProbeOptions { Enabled = false },
+ OnWarning = warnings.Add,
+ }, "drv-1");
+ await drv.InitializeAsync("{}", CancellationToken.None);
+
+ drv.GetDeviceState(Device)!.ReadStrategy.ShouldBe(ReadStrategy.MultiPacket);
+ warnings.ShouldBeEmpty();
+ }
+
+ [Fact]
+ public async Task User_forced_MultiPacket_on_Micro800_falls_back_to_WholeUdt_with_warning()
+ {
+ var warnings = new List();
+ var drv = new AbCipDriver(new AbCipDriverOptions
+ {
+ Devices = [new AbCipDeviceOptions(Micro, AbCipPlcFamily.Micro800,
+ ReadStrategy: ReadStrategy.MultiPacket)],
+ Probe = new AbCipProbeOptions { Enabled = false },
+ OnWarning = warnings.Add,
+ }, "drv-1");
+ await drv.InitializeAsync("{}", CancellationToken.None);
+
+ drv.GetDeviceState(Micro)!.ReadStrategy.ShouldBe(ReadStrategy.WholeUdt);
+ warnings.ShouldHaveSingleItem();
+ warnings[0].ShouldContain("Micro800");
+ warnings[0].ShouldContain("Multi-Service Packet");
+ }
+
+ [Fact]
+ public async Task Auto_on_Micro800_stays_Auto_at_init_planner_caps_to_WholeUdt_per_batch()
+ {
+ // Auto resolution does not warn on non-packing families — the per-batch planner caps
+ // the strategy to WholeUdt at dispatch time. Keeping Auto here means a future PR can
+ // change the family-cap policy in one place without touching device init.
+ var warnings = new List();
+ var drv = new AbCipDriver(new AbCipDriverOptions
+ {
+ Devices = [new AbCipDeviceOptions(Micro, AbCipPlcFamily.Micro800,
+ ReadStrategy: ReadStrategy.Auto)],
+ Probe = new AbCipProbeOptions { Enabled = false },
+ OnWarning = warnings.Add,
+ }, "drv-1");
+ await drv.InitializeAsync("{}", CancellationToken.None);
+
+ drv.GetDeviceState(Micro)!.ReadStrategy.ShouldBe(ReadStrategy.Auto);
+ warnings.ShouldBeEmpty();
+ }
+
+ // ---- Heuristic ----
+
+ [Fact]
+ public void Heuristic_picks_MultiPacket_when_subscribed_fraction_below_threshold()
+ {
+ // 5 of 50 subscribed = 0.10, threshold = 0.25 → MultiPacket
+ AbCipMultiPacketReadPlanner.ChooseStrategyForGroup(5, 50, 0.25).ShouldBe(ReadStrategy.MultiPacket);
+ }
+
+ [Fact]
+ public void Heuristic_picks_WholeUdt_when_subscribed_fraction_above_threshold()
+ {
+ // 40 of 50 subscribed = 0.80, threshold = 0.25 → WholeUdt
+ AbCipMultiPacketReadPlanner.ChooseStrategyForGroup(40, 50, 0.25).ShouldBe(ReadStrategy.WholeUdt);
+ }
+
+ [Fact]
+ public void Heuristic_at_threshold_boundary_picks_WholeUdt()
+ {
+ // Strictly less than → MultiPacket; equal → WholeUdt. Deterministic boundary behaviour
+ // so tests can pin exact picks without hand-wringing about float comparison drift.
+ AbCipMultiPacketReadPlanner.ChooseStrategyForGroup(10, 40, 0.25).ShouldBe(ReadStrategy.WholeUdt);
+ }
+
+ [Fact]
+ public void Heuristic_with_zero_total_members_defaults_to_WholeUdt()
+ {
+ AbCipMultiPacketReadPlanner.ChooseStrategyForGroup(0, 0, 0.25).ShouldBe(ReadStrategy.WholeUdt);
+ }
+
+ [Fact]
+ public void Heuristic_clamps_threshold_below_zero_to_zero()
+ {
+ // Negative threshold collapses to "never MultiPacket" — even a 0-of-N read picks WholeUdt.
+ AbCipMultiPacketReadPlanner.ChooseStrategyForGroup(0, 10, -0.5).ShouldBe(ReadStrategy.WholeUdt);
+ }
+
+ [Fact]
+ public void Heuristic_clamps_threshold_above_one_to_one()
+ {
+ // Threshold > 1 saturates so any subscribed fraction triggers MultiPacket.
+ AbCipMultiPacketReadPlanner.ChooseStrategyForGroup(9, 10, 5.0).ShouldBe(ReadStrategy.MultiPacket);
+ }
+
+ // ---- Driver-level dispatch / counters ----
+
+ private static AbCipTagDefinition BuildLargeUdt(string name, int memberCount)
+ {
+ var members = new AbCipStructureMember[memberCount];
+ for (var i = 0; i < memberCount; i++)
+ members[i] = new AbCipStructureMember($"M{i}", AbCipDataType.DInt);
+ return new AbCipTagDefinition(name, Device, name, AbCipDataType.Structure, Members: members);
+ }
+
+ private static AbCipDriverOptions BuildOptions(ReadStrategy strategy, double threshold = 0.25,
+ AbCipPlcFamily family = AbCipPlcFamily.ControlLogix, params AbCipTagDefinition[] tags)
+ {
+ var host = family == AbCipPlcFamily.Micro800 ? Micro : Device;
+ // Re-bind tag DeviceHostAddress when family flips so single-test reuse keeps
+ // working — the supplied tags are built against Device by default.
+ var rebuiltTags = tags.Select(t => new AbCipTagDefinition(
+ t.Name, host, t.TagPath, t.DataType, t.Writable, t.WriteIdempotent,
+ t.Members, t.SafetyTag, t.StringLength, t.Description)).ToArray();
+ return new AbCipDriverOptions
+ {
+ Devices = [new AbCipDeviceOptions(host, family, ReadStrategy: strategy,
+ MultiPacketSparsityThreshold: threshold)],
+ Probe = new AbCipProbeOptions { Enabled = false },
+ Tags = rebuiltTags,
+ };
+ }
+
+ [Fact]
+ public async Task Auto_with_sparse_subscription_dispatches_through_MultiPacket()
+ {
+ // 5 subscribed of 50 = 0.10 < 0.25 → MultiPacket
+ var udt = BuildLargeUdt("Tank", 50);
+ var options = BuildOptions(ReadStrategy.Auto, threshold: 0.25, tags: udt);
+ var factory = new FakeAbCipTagFactory();
+ var drv = new AbCipDriver(options, "drv-1", factory);
+ await drv.InitializeAsync("{}", CancellationToken.None);
+
+ var refs = Enumerable.Range(0, 5).Select(i => $"Tank.M{i}").ToArray();
+ await drv.ReadAsync(refs, CancellationToken.None);
+
+ var state = drv.GetDeviceState(Device)!;
+ state.MultiPacketGroupsExecuted.ShouldBe(1);
+ state.WholeUdtGroupsExecuted.ShouldBe(0);
+ }
+
+ [Fact]
+ public async Task Auto_with_dense_subscription_dispatches_through_WholeUdt()
+ {
+ // 40 subscribed of 50 = 0.80 > 0.25 → WholeUdt
+ var udt = BuildLargeUdt("Tank", 50);
+ var options = BuildOptions(ReadStrategy.Auto, threshold: 0.25, tags: udt);
+ var factory = new FakeAbCipTagFactory();
+ var drv = new AbCipDriver(options, "drv-1", factory);
+ await drv.InitializeAsync("{}", CancellationToken.None);
+
+ var refs = Enumerable.Range(0, 40).Select(i => $"Tank.M{i}").ToArray();
+ await drv.ReadAsync(refs, CancellationToken.None);
+
+ var state = drv.GetDeviceState(Device)!;
+ state.WholeUdtGroupsExecuted.ShouldBe(1);
+ state.MultiPacketGroupsExecuted.ShouldBe(0);
+ }
+
+ [Fact]
+ public async Task User_forced_MultiPacket_dispatches_through_MultiPacket_regardless_of_density()
+ {
+ // 40-of-50 dense reads still hit MultiPacket when the user forces it.
+ var udt = BuildLargeUdt("Tank", 50);
+ var options = BuildOptions(ReadStrategy.MultiPacket, threshold: 0.25, tags: udt);
+ var factory = new FakeAbCipTagFactory();
+ var drv = new AbCipDriver(options, "drv-1", factory);
+ await drv.InitializeAsync("{}", CancellationToken.None);
+
+ var refs = Enumerable.Range(0, 40).Select(i => $"Tank.M{i}").ToArray();
+ await drv.ReadAsync(refs, CancellationToken.None);
+
+ var state = drv.GetDeviceState(Device)!;
+ state.MultiPacketGroupsExecuted.ShouldBe(1);
+ state.WholeUdtGroupsExecuted.ShouldBe(0);
+ }
+
+ [Fact]
+ public async Task User_forced_WholeUdt_dispatches_through_WholeUdt_regardless_of_sparsity()
+ {
+ // 1 sparse read of 50 still hits WholeUdt when the user forces it. Note: the WholeUdt
+ // planner demotes 1-member groups to fallback because a single member doesn't beat the
+ // whole-UDT-buffer cost. Verify ReadCount on the parent's runtime stays zero — the
+ // member runtime did the work.
+ var udt = BuildLargeUdt("Tank", 50);
+ var options = BuildOptions(ReadStrategy.WholeUdt, threshold: 0.25, tags: udt);
+ var factory = new FakeAbCipTagFactory();
+ var drv = new AbCipDriver(options, "drv-1", factory);
+ await drv.InitializeAsync("{}", CancellationToken.None);
+
+ await drv.ReadAsync(["Tank.M0"], CancellationToken.None);
+
+ var state = drv.GetDeviceState(Device)!;
+ state.MultiPacketGroupsExecuted.ShouldBe(0);
+ // 1-member groups skip WholeUdt grouping per the existing planner contract — the
+ // counter increments only when the planner emits a group, not for the per-tag fallback.
+ state.WholeUdtGroupsExecuted.ShouldBe(0);
+ }
+
+ [Fact]
+ public async Task Threshold_tunable_higher_value_picks_MultiPacket_for_denser_reads()
+ {
+ // 12 of 50 = 0.24, threshold = 0.5 → MultiPacket (would have been WholeUdt at 0.25).
+ var udt = BuildLargeUdt("Tank", 50);
+ var options = BuildOptions(ReadStrategy.Auto, threshold: 0.5, tags: udt);
+ var factory = new FakeAbCipTagFactory();
+ var drv = new AbCipDriver(options, "drv-1", factory);
+ await drv.InitializeAsync("{}", CancellationToken.None);
+
+ var refs = Enumerable.Range(0, 12).Select(i => $"Tank.M{i}").ToArray();
+ await drv.ReadAsync(refs, CancellationToken.None);
+
+ drv.GetDeviceState(Device)!.MultiPacketGroupsExecuted.ShouldBe(1);
+ }
+
+ [Fact]
+ public async Task Auto_on_Micro800_caps_to_WholeUdt_even_when_sparse()
+ {
+ // Family doesn't support request packing → Auto must NEVER pick MultiPacket.
+ var udt = BuildLargeUdt("Tank", 50);
+ var options = BuildOptions(ReadStrategy.Auto, threshold: 0.25,
+ family: AbCipPlcFamily.Micro800, tags: udt);
+ var factory = new FakeAbCipTagFactory();
+ var drv = new AbCipDriver(options, "drv-1", factory);
+ await drv.InitializeAsync("{}", CancellationToken.None);
+
+ var refs = Enumerable.Range(0, 5).Select(i => $"Tank.M{i}").ToArray();
+ await drv.ReadAsync(refs, CancellationToken.None);
+
+ var state = drv.GetDeviceState(Micro)!;
+ state.MultiPacketGroupsExecuted.ShouldBe(0);
+ state.WholeUdtGroupsExecuted.ShouldBe(1);
+ }
+
+ // ---- Family-profile compatibility ----
+
+ [Fact]
+ public void Family_profiles_advertise_request_packing_correctly()
+ {
+ AbCipPlcFamilyProfile.ControlLogix.SupportsRequestPacking.ShouldBeTrue();
+ AbCipPlcFamilyProfile.CompactLogix.SupportsRequestPacking.ShouldBeTrue();
+ AbCipPlcFamilyProfile.GuardLogix.SupportsRequestPacking.ShouldBeTrue();
+ AbCipPlcFamilyProfile.Micro800.SupportsRequestPacking.ShouldBeFalse();
+ }
+
+ // ---- DTO round-trip ----
+
+ [Fact]
+ public async Task DTO_round_trips_ReadStrategy_MultiPacket_through_config_json()
+ {
+ var json = """
+ {
+ "Devices": [
+ {
+ "HostAddress": "ab://10.0.0.5/1,0",
+ "PlcFamily": "ControlLogix",
+ "ReadStrategy": "MultiPacket",
+ "MultiPacketSparsityThreshold": 0.5
+ }
+ ],
+ "Probe": { "Enabled": false }
+ }
+ """;
+ var drv = AbCipDriverFactoryExtensions.CreateInstance("drv-1", json);
+ await drv.InitializeAsync(json, CancellationToken.None);
+
+ var state = drv.GetDeviceState(Device)!;
+ state.ReadStrategy.ShouldBe(ReadStrategy.MultiPacket);
+ state.Options.MultiPacketSparsityThreshold.ShouldBe(0.5);
+ }
+
+ [Fact]
+ public async Task DTO_round_trips_ReadStrategy_WholeUdt_through_config_json()
+ {
+ var json = """
+ {
+ "Devices": [
+ {
+ "HostAddress": "ab://10.0.0.5/1,0",
+ "PlcFamily": "ControlLogix",
+ "ReadStrategy": "WholeUdt"
+ }
+ ],
+ "Probe": { "Enabled": false }
+ }
+ """;
+ var drv = AbCipDriverFactoryExtensions.CreateInstance("drv-1", json);
+ await drv.InitializeAsync(json, CancellationToken.None);
+ drv.GetDeviceState(Device)!.ReadStrategy.ShouldBe(ReadStrategy.WholeUdt);
+ }
+
+ [Fact]
+ public async Task DTO_omitted_ReadStrategy_falls_back_to_Auto_with_default_threshold()
+ {
+ var json = """
+ {
+ "Devices": [
+ {
+ "HostAddress": "ab://10.0.0.5/1,0",
+ "PlcFamily": "ControlLogix"
+ }
+ ],
+ "Probe": { "Enabled": false }
+ }
+ """;
+ var drv = AbCipDriverFactoryExtensions.CreateInstance("drv-1", json);
+ await drv.InitializeAsync(json, CancellationToken.None);
+
+ var state = drv.GetDeviceState(Device)!;
+ state.ReadStrategy.ShouldBe(ReadStrategy.Auto);
+ state.Options.MultiPacketSparsityThreshold.ShouldBe(0.25);
+ }
+
+ // ---- Planner output shape (sanity) ----
+
+ [Fact]
+ public void MultiPacketPlanner_groups_subscribed_members_by_parent()
+ {
+ var udt = BuildLargeUdt("Tank", 50);
+ var tagsByName = new Dictionary(StringComparer.OrdinalIgnoreCase)
+ {
+ ["Tank"] = udt,
+ };
+ for (var i = 0; i < 50; i++)
+ {
+ tagsByName[$"Tank.M{i}"] = new AbCipTagDefinition(
+ $"Tank.M{i}", Device, $"Tank.M{i}", AbCipDataType.DInt);
+ }
+
+ var refs = new[] { "Tank.M0", "Tank.M3", "Tank.M7" };
+ var plan = AbCipMultiPacketReadPlanner.Build(refs, tagsByName);
+
+ plan.Batches.Count.ShouldBe(1);
+ plan.Batches[0].ParentName.ShouldBe("Tank");
+ plan.Batches[0].Members.Count.ShouldBe(3);
+ plan.Fallbacks.ShouldBeEmpty();
+ }
+
+ [Fact]
+ public void MultiPacketPlanner_does_not_demote_singletons_unlike_WholeUdt_planner()
+ {
+ // A 1-of-N read is the canonical sparse case — MultiPacket emits a Batch with one
+ // member where WholeUdt would demote to fallback. This is the load-bearing difference.
+ var udt = BuildLargeUdt("Tank", 50);
+ var tagsByName = new Dictionary(StringComparer.OrdinalIgnoreCase)
+ {
+ ["Tank"] = udt,
+ ["Tank.M0"] = new AbCipTagDefinition("Tank.M0", Device, "Tank.M0", AbCipDataType.DInt),
+ };
+
+ var plan = AbCipMultiPacketReadPlanner.Build(["Tank.M0"], tagsByName);
+
+ plan.Batches.Count.ShouldBe(1);
+ plan.Batches[0].Members.Count.ShouldBe(1);
+ plan.Fallbacks.ShouldBeEmpty();
+ }
+}