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(); + } +}