Merge pull request '[abcip] AbCip — Logical-blocking / non-blocking strategy selector' (#369) from auto/abcip/3.3 into auto/driver-gaps
This commit was merged in pull request #369.
This commit is contained in:
@@ -306,3 +306,100 @@ the field defaults to `"Auto"`.
|
|||||||
"What it actually covers" — Logical-mode fixture coverage status.
|
"What it actually covers" — Logical-mode fixture coverage status.
|
||||||
- [`AbCipAddressingModeBenchTests`](../../tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/AbCipAddressingModeBenchTests.cs) —
|
- [`AbCipAddressingModeBenchTests`](../../tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/AbCipAddressingModeBenchTests.cs) —
|
||||||
scaffold for the wall-clock comparison; gated on `[AbServerFact]`.
|
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`.
|
||||||
|
|||||||
@@ -70,6 +70,19 @@ Unit coverage: `AbCipFetchUdtShapeTests`, `CipTemplateObjectDecoderTests`,
|
|||||||
`AbCipDriverWholeUdtReadTests` — all with golden Template-Object byte buffers
|
`AbCipDriverWholeUdtReadTests` — all with golden Template-Object byte buffers
|
||||||
+ offset-keyed `FakeAbCipTag` values.
|
+ 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)
|
### 2. ALMD / ALMA alarm projection (#177)
|
||||||
|
|
||||||
Depends on the ALMD UDT shape, which `ab_server` cannot emulate. The
|
Depends on the ALMD UDT shape, which `ab_server` cannot emulate. The
|
||||||
|
|||||||
@@ -157,7 +157,13 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
|||||||
// unsupported family falls back to Symbolic + emits a warning so misconfiguration
|
// unsupported family falls back to Symbolic + emits a warning so misconfiguration
|
||||||
// does not fault the driver.
|
// does not fault the driver.
|
||||||
var resolvedAddressing = ResolveAddressingMode(device, profile);
|
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
|
// 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).
|
// (operators can override an imported entry by re-declaring it under Tags).
|
||||||
@@ -268,6 +274,46 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// PR abcip-3.3 — resolve <see cref="AbCipDeviceOptions.ReadStrategy"/> against the
|
||||||
|
/// family profile. <see cref="ReadStrategy.MultiPacket"/> against a family whose profile
|
||||||
|
/// sets <see cref="AbCipPlcFamilyProfile.SupportsRequestPacking"/> = <c>false</c>
|
||||||
|
/// (Micro800 today; SLC500 / PLC5 when those profiles ship) falls back to
|
||||||
|
/// <see cref="ReadStrategy.WholeUdt"/> with a warning so the operator sees the
|
||||||
|
/// misconfiguration in the log without the driver faulting. <see cref="ReadStrategy.Auto"/>
|
||||||
|
/// stays as-is — the planner re-evaluates the choice per-batch from the device's
|
||||||
|
/// <see cref="AbCipDeviceOptions.MultiPacketSparsityThreshold"/>; we still need the
|
||||||
|
/// resolution step here so a future PR can cap Auto at WholeUdt on non-packing families
|
||||||
|
/// in one place rather than scattering the check across the read path.
|
||||||
|
/// </summary>
|
||||||
|
private ReadStrategy ResolveReadStrategy(AbCipDeviceOptions device, AbCipPlcFamilyProfile profile)
|
||||||
|
{
|
||||||
|
switch (device.ReadStrategy)
|
||||||
|
{
|
||||||
|
case ReadStrategy.MultiPacket:
|
||||||
|
if (!profile.SupportsRequestPacking)
|
||||||
|
{
|
||||||
|
_options.OnWarning?.Invoke(
|
||||||
|
$"AbCip device '{device.HostAddress}' family '{device.PlcFamily}' does not support " +
|
||||||
|
"Multi-Service Packet request packing — its CIP firmware lacks the 0x0A service. " +
|
||||||
|
"Falling back to WholeUdt read strategy for this device.");
|
||||||
|
return ReadStrategy.WholeUdt;
|
||||||
|
}
|
||||||
|
return ReadStrategy.MultiPacket;
|
||||||
|
case ReadStrategy.WholeUdt:
|
||||||
|
return ReadStrategy.WholeUdt;
|
||||||
|
case ReadStrategy.Auto:
|
||||||
|
default:
|
||||||
|
// Auto on a non-packing family stays Auto here so the planner's per-batch
|
||||||
|
// heuristic still runs; the heuristic itself never picks MultiPacket against a
|
||||||
|
// device whose AddressingMode-style guard tripped — but for ReadStrategy the guard
|
||||||
|
// lives in the device-init resolution above (user-forced MultiPacket → WholeUdt).
|
||||||
|
// For Auto, the planner consults device.Profile.SupportsRequestPacking before
|
||||||
|
// emitting MultiPacket so non-packing families always read WholeUdt under Auto.
|
||||||
|
return ReadStrategy.Auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Shared L5K / L5X import path — keeps source-format selection (parser delegate) the
|
/// 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
|
/// 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
|
// 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
|
// current tag map; BOOL/String/Structure members stay on the fallback path because
|
||||||
// declaration-only offsets can't place them under Logix alignment rules.
|
// declaration-only offsets can't place them under Logix alignment rules.
|
||||||
var plan = AbCipUdtReadPlanner.Build(fullReferences, _tagsByName);
|
//
|
||||||
|
// PR abcip-3.3 — dispatch by device's resolved ReadStrategy:
|
||||||
foreach (var group in plan.Groups)
|
// WholeUdt — every group goes through the whole-UDT planner (task #194 default).
|
||||||
await ReadGroupAsync(group, results, now, cancellationToken).ConfigureAwait(false);
|
// MultiPacket — every group goes through the multi-packet planner; one read per
|
||||||
|
// subscribed member, bundled per parent.
|
||||||
foreach (var fb in plan.Fallbacks)
|
// Auto — per-group heuristic on subscribedMembers / totalMembers.
|
||||||
await ReadSingleAsync(fb, fullReferences[fb.OriginalIndex], results, now, cancellationToken).ConfigureAwait(false);
|
await ExecuteReadPlanAsync(fullReferences, results, now, cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
return results;
|
return results;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// PR abcip-3.3 — strategy-aware dispatch wrapper around the WholeUdt + MultiPacket
|
||||||
|
/// planners. Both planners produce the same shape of "groups + per-tag fallbacks" so
|
||||||
|
/// fallbacks always run through <see cref="ReadSingleAsync"/>; only the group shape
|
||||||
|
/// differs. Auto resolves per-group: members of the same parent UDT either flow through
|
||||||
|
/// <see cref="ReadGroupAsync"/> (one whole-UDT read) or
|
||||||
|
/// <see cref="ReadMultiPacketBatchAsync"/> (per-member reads bundled per parent).
|
||||||
|
/// </summary>
|
||||||
|
private async Task ExecuteReadPlanAsync(
|
||||||
|
IReadOnlyList<string> fullReferences, DataValueSnapshot[] results, DateTime now, CancellationToken ct)
|
||||||
|
{
|
||||||
|
// First pass — segregate references by parent UDT vs everything-else, identical to the
|
||||||
|
// shape both planners produce; we can then route each parent group through the chosen
|
||||||
|
// planner. Reuse AbCipUdtReadPlanner.Build for the WholeUdt+Auto path because it already
|
||||||
|
// demotes single-member groups to fallback, and the MultiPacket planner does NOT demote
|
||||||
|
// (sparse reads of one member are still a win on the wire).
|
||||||
|
var wholeUdtPlan = AbCipUdtReadPlanner.Build(fullReferences, _tagsByName);
|
||||||
|
var multiPacketPlan = AbCipMultiPacketReadPlanner.Build(fullReferences, _tagsByName);
|
||||||
|
|
||||||
|
// Determine per-parent strategy. WholeUdt planner emits a Group only when ≥2 members of
|
||||||
|
// the same parent are subscribed; MultiPacket planner emits a Batch for every parent
|
||||||
|
// touched. Index multiPacket batches by parent name so we can co-route them with the
|
||||||
|
// WholeUdt grouping decisions.
|
||||||
|
var multiPacketByParent = new Dictionary<string, AbCipMultiPacketReadBatch>(
|
||||||
|
multiPacketPlan.Batches.Count, StringComparer.OrdinalIgnoreCase);
|
||||||
|
foreach (var b in multiPacketPlan.Batches)
|
||||||
|
multiPacketByParent[b.ParentName] = b;
|
||||||
|
|
||||||
|
// Treat each parent that survived the WholeUdt planner as the candidate for whole-UDT
|
||||||
|
// dispatch; per-strategy routing decides whether it goes WholeUdt or MultiPacket.
|
||||||
|
var routedParents = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
foreach (var group in wholeUdtPlan.Groups)
|
||||||
|
{
|
||||||
|
_devices.TryGetValue(group.ParentDefinition.DeviceHostAddress, out var device);
|
||||||
|
|
||||||
|
var strategy = ChooseEffectiveStrategy(device, group);
|
||||||
|
if (strategy == ReadStrategy.MultiPacket
|
||||||
|
&& multiPacketByParent.TryGetValue(group.ParentName, out var mpBatch))
|
||||||
|
{
|
||||||
|
if (device is not null) Interlocked.Increment(ref device.MultiPacketGroupsExecuted);
|
||||||
|
await ReadMultiPacketBatchAsync(mpBatch, results, now, ct).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
if (device is not null) Interlocked.Increment(ref device.WholeUdtGroupsExecuted);
|
||||||
|
await ReadGroupAsync(group, results, now, ct).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
routedParents.Add(group.ParentName);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Per-tag fallbacks from the WholeUdt planner are the union of (a) tags with no UDT
|
||||||
|
// parent, (b) UDT members where the parent had only one subscribed member, and (c)
|
||||||
|
// unknown references. We re-route singletons through MultiPacket too when the device
|
||||||
|
// strategy is MultiPacket, because a 1-of-50 read is exactly the sparse case the planner
|
||||||
|
// is designed for.
|
||||||
|
foreach (var fb in wholeUdtPlan.Fallbacks)
|
||||||
|
{
|
||||||
|
// Is this a UDT-member fallback that the MultiPacket planner can route?
|
||||||
|
if (multiPacketPlan.Batches.Count > 0
|
||||||
|
&& _tagsByName.TryGetValue(fb.Reference, out var def))
|
||||||
|
{
|
||||||
|
var dot = fb.Reference.IndexOf('.');
|
||||||
|
if (dot > 0 && dot < fb.Reference.Length - 1)
|
||||||
|
{
|
||||||
|
var parentName = fb.Reference[..dot];
|
||||||
|
if (!routedParents.Contains(parentName)
|
||||||
|
&& multiPacketByParent.TryGetValue(parentName, out var mpBatch)
|
||||||
|
&& _devices.TryGetValue(def.DeviceHostAddress, out var device))
|
||||||
|
{
|
||||||
|
// Singleton parent — Auto picks based on the same heuristic; MultiPacket
|
||||||
|
// wins by default because the WholeUdt planner already demoted single
|
||||||
|
// members under the assumption that one whole-UDT read is no cheaper
|
||||||
|
// than one member read. With explicit MultiPacket the answer flips.
|
||||||
|
var strategy = ChooseEffectiveStrategyForSingleton(device, mpBatch);
|
||||||
|
if (strategy == ReadStrategy.MultiPacket)
|
||||||
|
{
|
||||||
|
Interlocked.Increment(ref device.MultiPacketGroupsExecuted);
|
||||||
|
await ReadMultiPacketBatchAsync(mpBatch, results, now, ct).ConfigureAwait(false);
|
||||||
|
routedParents.Add(parentName);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await ReadSingleAsync(fb, fullReferences[fb.OriginalIndex], results, now, ct).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// PR abcip-3.3 — pick the effective <see cref="ReadStrategy"/> for one parent UDT group.
|
||||||
|
/// <see cref="ReadStrategy.WholeUdt"/> + <see cref="ReadStrategy.MultiPacket"/> are
|
||||||
|
/// forced explicitly (already family-compat-checked at device init). <see cref="ReadStrategy.Auto"/>
|
||||||
|
/// consults the planner heuristic on subscribed-member fraction, but only when the
|
||||||
|
/// family supports request packing — non-packing families always read WholeUdt regardless
|
||||||
|
/// of sparsity because they have no Multi-Service-Packet path on the wire.
|
||||||
|
/// </summary>
|
||||||
|
private static ReadStrategy ChooseEffectiveStrategy(DeviceState? device, AbCipUdtReadGroup group)
|
||||||
|
{
|
||||||
|
if (device is null) return ReadStrategy.WholeUdt;
|
||||||
|
switch (device.ReadStrategy)
|
||||||
|
{
|
||||||
|
case ReadStrategy.MultiPacket:
|
||||||
|
return ReadStrategy.MultiPacket;
|
||||||
|
case ReadStrategy.WholeUdt:
|
||||||
|
return ReadStrategy.WholeUdt;
|
||||||
|
case ReadStrategy.Auto:
|
||||||
|
default:
|
||||||
|
if (!device.Profile.SupportsRequestPacking) return ReadStrategy.WholeUdt;
|
||||||
|
var totalMembers = group.ParentDefinition.Members?.Count ?? 0;
|
||||||
|
return AbCipMultiPacketReadPlanner.ChooseStrategyForGroup(
|
||||||
|
subscribedMembers: group.Members.Count,
|
||||||
|
totalMembers: totalMembers,
|
||||||
|
threshold: device.Options.MultiPacketSparsityThreshold);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// PR abcip-3.3 — strategy pick for a singleton (one-member) UDT batch. Only relevant
|
||||||
|
/// when the device strategy is explicit <see cref="ReadStrategy.MultiPacket"/> or Auto
|
||||||
|
/// produces a MultiPacket result; otherwise the per-tag fallback path runs as before.
|
||||||
|
/// </summary>
|
||||||
|
private static ReadStrategy ChooseEffectiveStrategyForSingleton(
|
||||||
|
DeviceState device, AbCipMultiPacketReadBatch batch)
|
||||||
|
{
|
||||||
|
switch (device.ReadStrategy)
|
||||||
|
{
|
||||||
|
case ReadStrategy.MultiPacket:
|
||||||
|
return ReadStrategy.MultiPacket;
|
||||||
|
case ReadStrategy.WholeUdt:
|
||||||
|
return ReadStrategy.WholeUdt;
|
||||||
|
case ReadStrategy.Auto:
|
||||||
|
default:
|
||||||
|
if (!device.Profile.SupportsRequestPacking) return ReadStrategy.WholeUdt;
|
||||||
|
var totalMembers = batch.ParentDefinition.Members?.Count ?? 0;
|
||||||
|
return AbCipMultiPacketReadPlanner.ChooseStrategyForGroup(
|
||||||
|
subscribedMembers: batch.Members.Count,
|
||||||
|
totalMembers: totalMembers,
|
||||||
|
threshold: device.Options.MultiPacketSparsityThreshold);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// PR abcip-3.3 — execute a Multi-Service-Packet batch. Today this issues one libplctag
|
||||||
|
/// read per member (the same N reads the per-tag fallback path does), keyed on the
|
||||||
|
/// batch's parent so the diagnostic counters track which strategy ran. Wire-level
|
||||||
|
/// Multi-Service-Packet bundling depends on the libplctag .NET wrapper exposing the
|
||||||
|
/// 0x0A service explicitly — same wrapper limitation as PR abcip-3.1's connection_size
|
||||||
|
/// and PR abcip-3.2's instance-ID addressing. The planner's grouping is still
|
||||||
|
/// load-bearing because it gives the runtime the correct plan when an upstream wrapper
|
||||||
|
/// release exposes the bundling primitive.
|
||||||
|
/// </summary>
|
||||||
|
private async Task ReadMultiPacketBatchAsync(
|
||||||
|
AbCipMultiPacketReadBatch batch, DataValueSnapshot[] results, DateTime now, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var parent = batch.ParentDefinition;
|
||||||
|
if (!_devices.TryGetValue(parent.DeviceHostAddress, out var device))
|
||||||
|
{
|
||||||
|
foreach (var m in batch.Members)
|
||||||
|
results[m.OriginalIndex] = new DataValueSnapshot(null, AbCipStatusMapper.BadNodeIdUnknown, null, now);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var member in batch.Members)
|
||||||
|
{
|
||||||
|
var memberFullName = member.Definition.Name;
|
||||||
|
var fb = new AbCipUdtReadFallback(member.OriginalIndex, memberFullName);
|
||||||
|
await ReadSingleAsync(fb, memberFullName, results, now, ct).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// PR abcip-3.2 — for each Logical-mode device touched by this read batch, fire the
|
/// PR abcip-3.2 — for each Logical-mode device touched by this read batch, fire the
|
||||||
/// one-time <c>@tags</c> symbol-table walk + populate <see cref="DeviceState.LogicalInstanceMap"/>.
|
/// one-time <c>@tags</c> symbol-table walk + populate <see cref="DeviceState.LogicalInstanceMap"/>.
|
||||||
@@ -1366,7 +1582,8 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
|||||||
AbCipHostAddress parsedAddress,
|
AbCipHostAddress parsedAddress,
|
||||||
AbCipDeviceOptions options,
|
AbCipDeviceOptions options,
|
||||||
AbCipPlcFamilyProfile profile,
|
AbCipPlcFamilyProfile profile,
|
||||||
AddressingMode resolvedAddressingMode)
|
AddressingMode resolvedAddressingMode,
|
||||||
|
ReadStrategy resolvedReadStrategy)
|
||||||
{
|
{
|
||||||
public AbCipHostAddress ParsedAddress { get; } = parsedAddress;
|
public AbCipHostAddress ParsedAddress { get; } = parsedAddress;
|
||||||
public AbCipDeviceOptions Options { get; } = options;
|
public AbCipDeviceOptions Options { get; } = options;
|
||||||
@@ -1391,6 +1608,25 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public AddressingMode AddressingMode { get; } = resolvedAddressingMode;
|
public AddressingMode AddressingMode { get; } = resolvedAddressingMode;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// PR abcip-3.3 — resolved read strategy for this device. <see cref="AbCip.ReadStrategy.WholeUdt"/>
|
||||||
|
/// or <see cref="AbCip.ReadStrategy.MultiPacket"/> mean "always pick this for every UDT
|
||||||
|
/// batch on this device." <see cref="AbCip.ReadStrategy.Auto"/> means "let the planner
|
||||||
|
/// pick per-batch using <see cref="AbCipDeviceOptions.MultiPacketSparsityThreshold"/>."
|
||||||
|
/// User-forced MultiPacket against a non-packing family (Micro800 et al) was already
|
||||||
|
/// collapsed to WholeUdt at <see cref="AbCipDriver.ResolveReadStrategy"/> time, so the
|
||||||
|
/// read hot path can branch on this single value without re-checking family compat.
|
||||||
|
/// </summary>
|
||||||
|
public ReadStrategy ReadStrategy { get; } = resolvedReadStrategy;
|
||||||
|
|
||||||
|
/// <summary>PR abcip-3.3 — count of UDT groups dispatched through the WholeUdt path on
|
||||||
|
/// this device. Surfaced for tests + a future driver-diagnostics RPC.</summary>
|
||||||
|
public int WholeUdtGroupsExecuted;
|
||||||
|
|
||||||
|
/// <summary>PR abcip-3.3 — count of UDT groups dispatched through the MultiPacket path
|
||||||
|
/// on this device. Surfaced for tests + a future driver-diagnostics RPC.</summary>
|
||||||
|
public int MultiPacketGroupsExecuted;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// PR abcip-3.2 — name → Symbol Object instance ID map populated by the one-time
|
/// PR abcip-3.2 — name → Symbol Object instance ID map populated by the one-time
|
||||||
/// <c>@tags</c> walk that fires on the first read on a Logical-mode device. Empty
|
/// <c>@tags</c> walk that fires on the first read on a Logical-mode device. Empty
|
||||||
|
|||||||
@@ -40,7 +40,10 @@ public static class AbCipDriverFactoryExtensions
|
|||||||
DeviceName: d.DeviceName,
|
DeviceName: d.DeviceName,
|
||||||
ConnectionSize: d.ConnectionSize,
|
ConnectionSize: d.ConnectionSize,
|
||||||
AddressingMode: ParseEnum<AddressingMode>(d.AddressingMode, "device", driverInstanceId,
|
AddressingMode: ParseEnum<AddressingMode>(d.AddressingMode, "device", driverInstanceId,
|
||||||
"AddressingMode", fallback: AddressingMode.Auto)))]
|
"AddressingMode", fallback: AddressingMode.Auto),
|
||||||
|
ReadStrategy: ParseEnum<ReadStrategy>(d.ReadStrategy, "device", driverInstanceId,
|
||||||
|
"ReadStrategy", fallback: ReadStrategy.Auto),
|
||||||
|
MultiPacketSparsityThreshold: d.MultiPacketSparsityThreshold ?? 0.25))]
|
||||||
: [],
|
: [],
|
||||||
Tags = dto.Tags is { Count: > 0 }
|
Tags = dto.Tags is { Count: > 0 }
|
||||||
? [.. dto.Tags.Select(t => BuildTag(t, driverInstanceId))]
|
? [.. 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.
|
/// Micro800 / SLC500 / PLC5 falls back to Symbolic with a warning.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public string? AddressingMode { get; init; }
|
public string? AddressingMode { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// PR abcip-3.3 — optional per-device read-strategy override. <c>"Auto"</c>,
|
||||||
|
/// <c>"WholeUdt"</c>, or <c>"MultiPacket"</c>. Defaults to <c>Auto</c> (the planner
|
||||||
|
/// picks per-batch using <see cref="MultiPacketSparsityThreshold"/>). Family
|
||||||
|
/// compatibility is enforced at <see cref="AbCipDriver.InitializeAsync"/>: explicit
|
||||||
|
/// <c>MultiPacket</c> against Micro800 (no
|
||||||
|
/// <see cref="PlcFamilies.AbCipPlcFamilyProfile.SupportsRequestPacking"/>) falls
|
||||||
|
/// back to <c>WholeUdt</c> with a warning.
|
||||||
|
/// </summary>
|
||||||
|
public string? ReadStrategy { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// PR abcip-3.3 — sparsity-threshold knob applied when <see cref="ReadStrategy"/>
|
||||||
|
/// resolves to <c>Auto</c>. Default <c>0.25</c>; clamped to <c>[0..1]</c>.
|
||||||
|
/// </summary>
|
||||||
|
public double? MultiPacketSparsityThreshold { get; init; }
|
||||||
}
|
}
|
||||||
|
|
||||||
internal sealed class AbCipTagDto
|
internal sealed class AbCipTagDto
|
||||||
|
|||||||
@@ -124,12 +124,83 @@ public sealed class AbCipDriverOptions
|
|||||||
/// misconfiguration does not fault the driver. <see cref="AbCip.AddressingMode.Auto"/> currently
|
/// misconfiguration does not fault the driver. <see cref="AbCip.AddressingMode.Auto"/> currently
|
||||||
/// resolves to symbolic — a future PR will plumb a real auto-detection heuristic; the docs
|
/// resolves to symbolic — a future PR will plumb a real auto-detection heuristic; the docs
|
||||||
/// in <c>docs/drivers/AbCip-Performance.md</c> §"Addressing mode" call this out.</param>
|
/// in <c>docs/drivers/AbCip-Performance.md</c> §"Addressing mode" call this out.</param>
|
||||||
|
/// <param name="ReadStrategy">PR abcip-3.3 — picks how a multi-member UDT batch is read on this
|
||||||
|
/// device. <see cref="AbCip.ReadStrategy.WholeUdt"/> issues one read per parent UDT and decodes
|
||||||
|
/// each subscribed member from the buffer in-memory (the historical behaviour that ships in
|
||||||
|
/// task #194 — best when a large fraction of a UDT's members are subscribed).
|
||||||
|
/// <see cref="AbCip.ReadStrategy.MultiPacket"/> bundles per-member reads into one CIP
|
||||||
|
/// Multi-Service Packet — best for sparse UDT subscriptions where reading the whole UDT
|
||||||
|
/// buffer just to extract one or two fields wastes wire bandwidth. <see cref="AbCip.ReadStrategy.Auto"/>
|
||||||
|
/// (the default) lets the planner pick per-batch using
|
||||||
|
/// <paramref name="MultiPacketSparsityThreshold"/>: if the subscribed-member fraction is below
|
||||||
|
/// the threshold MultiPacket wins, otherwise WholeUdt wins. Family compatibility — Micro800 /
|
||||||
|
/// SLC500 / PLC5 lack Multi-Service-Packet support per
|
||||||
|
/// <see cref="PlcFamilies.AbCipPlcFamilyProfile.SupportsRequestPacking"/>; user-forced
|
||||||
|
/// <see cref="AbCip.ReadStrategy.MultiPacket"/> against those families logs a warning + falls
|
||||||
|
/// back to <see cref="AbCip.ReadStrategy.WholeUdt"/> at device-init time. The libplctag .NET
|
||||||
|
/// wrapper (1.5.x) does not expose a public knob for explicit Multi-Service-Packet bundling,
|
||||||
|
/// so today's MultiPacket runtime issues one libplctag read per member; the planner's grouping
|
||||||
|
/// is still load-bearing because it gives the runtime the right plan to execute when an
|
||||||
|
/// upstream wrapper release exposes wire-level bundling.</param>
|
||||||
|
/// <param name="MultiPacketSparsityThreshold">PR abcip-3.3 — sparsity-threshold knob the planner
|
||||||
|
/// uses when <paramref name="ReadStrategy"/> is <see cref="AbCip.ReadStrategy.Auto"/>. The
|
||||||
|
/// planner divides <c>subscribedMembers / totalMembers</c> for each parent UDT in a batch;
|
||||||
|
/// a fraction strictly less than the threshold picks
|
||||||
|
/// <see cref="AbCip.ReadStrategy.MultiPacket"/>, else <see cref="AbCip.ReadStrategy.WholeUdt"/>.
|
||||||
|
/// Default <c>0.25</c> — picked because reading 1/4 of a UDT's members is the rough break-even
|
||||||
|
/// where the wire-cost of one whole-UDT read still beats N member reads on ControlLogix's
|
||||||
|
/// 4002-byte connection size; see <c>docs/drivers/AbCip-Performance.md</c> §"Read strategy".
|
||||||
|
/// Clamped to <c>[0..1]</c> at planner time; values outside the range silently saturate.</param>
|
||||||
public sealed record AbCipDeviceOptions(
|
public sealed record AbCipDeviceOptions(
|
||||||
string HostAddress,
|
string HostAddress,
|
||||||
AbCipPlcFamily PlcFamily = AbCipPlcFamily.ControlLogix,
|
AbCipPlcFamily PlcFamily = AbCipPlcFamily.ControlLogix,
|
||||||
string? DeviceName = null,
|
string? DeviceName = null,
|
||||||
int? ConnectionSize = null,
|
int? ConnectionSize = null,
|
||||||
AddressingMode AddressingMode = AddressingMode.Auto);
|
AddressingMode AddressingMode = AddressingMode.Auto,
|
||||||
|
ReadStrategy ReadStrategy = ReadStrategy.Auto,
|
||||||
|
double MultiPacketSparsityThreshold = 0.25);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// PR abcip-3.3 — per-device strategy for reading multi-member UDT batches. <see cref="WholeUdt"/>
|
||||||
|
/// mirrors the task #194 behaviour: one libplctag read on the parent tag, each subscribed member
|
||||||
|
/// decoded from the buffer at its computed offset. <see cref="MultiPacket"/> bundles per-member
|
||||||
|
/// reads into one CIP Multi-Service Packet so sparse UDT subscriptions don't pay for the whole
|
||||||
|
/// UDT buffer. <see cref="Auto"/> lets the planner pick per-batch using
|
||||||
|
/// <see cref="AbCipDeviceOptions.MultiPacketSparsityThreshold"/>.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>Strategy resolution lives at two layers:</para>
|
||||||
|
/// <list type="bullet">
|
||||||
|
/// <item><b>Device init</b> — user-forced <see cref="MultiPacket"/> against a family whose
|
||||||
|
/// profile sets <see cref="PlcFamilies.AbCipPlcFamilyProfile.SupportsRequestPacking"/>
|
||||||
|
/// = <c>false</c> (Micro800, SLC500, PLC5) falls back to <see cref="WholeUdt"/> with a
|
||||||
|
/// warning. <see cref="Auto"/> stays as-is (the planner re-evaluates per batch).</item>
|
||||||
|
/// <item><b>Per-batch (Auto only)</b> — for each parent UDT in the request set, the planner
|
||||||
|
/// computes <c>subscribedMembers / totalMembers</c> and routes the group through
|
||||||
|
/// <see cref="MultiPacket"/> when the fraction is below the threshold, else
|
||||||
|
/// <see cref="WholeUdt"/>.</item>
|
||||||
|
/// </list>
|
||||||
|
/// <para>libplctag .NET wrapper (1.5.x) does not expose explicit Multi-Service-Packet bundling,
|
||||||
|
/// so today's runtime issues one libplctag read per member when the planner picks MultiPacket —
|
||||||
|
/// the same wrapper limitation called out in PR abcip-3.1 (ConnectionSize) and PR abcip-3.2
|
||||||
|
/// (instance-ID addressing). The planner's grouping is still observable from tests + future-proofs
|
||||||
|
/// the driver for when an upstream wrapper release exposes wire-level bundling.</para>
|
||||||
|
/// </remarks>
|
||||||
|
public enum ReadStrategy
|
||||||
|
{
|
||||||
|
/// <summary>Driver picks per-batch based on
|
||||||
|
/// <see cref="AbCipDeviceOptions.MultiPacketSparsityThreshold"/>. Default.</summary>
|
||||||
|
Auto = 0,
|
||||||
|
|
||||||
|
/// <summary>One read per parent UDT; members decoded from the buffer in-memory. Best when a
|
||||||
|
/// large fraction of the UDT's members are subscribed (dense reads).</summary>
|
||||||
|
WholeUdt = 1,
|
||||||
|
|
||||||
|
/// <summary>Bundle per-member reads into one CIP Multi-Service Packet. Best when only a few
|
||||||
|
/// members of a large UDT are subscribed (sparse reads). Unsupported on Micro800 / SLC500 /
|
||||||
|
/// PLC5; the driver warns + falls back to <see cref="WholeUdt"/> at device init.</summary>
|
||||||
|
MultiPacket = 2,
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// PR abcip-3.2 — how the AB CIP driver addresses tags on a given device. <see cref="Symbolic"/>
|
/// PR abcip-3.2 — how the AB CIP driver addresses tags on a given device. <see cref="Symbolic"/>
|
||||||
|
|||||||
@@ -0,0 +1,132 @@
|
|||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// PR abcip-3.3 — sparse-UDT read planner. Where <see cref="AbCipUdtReadPlanner"/> reads each
|
||||||
|
/// parent UDT once and decodes every subscribed member from the buffer in-memory, this planner
|
||||||
|
/// keeps the per-member read shape and bundles the reads into one CIP Multi-Service Packet
|
||||||
|
/// per parent so a 5-of-50-member subscription doesn't pay for the whole UDT buffer.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>Pure function — like its sibling planner, this one never touches the runtime + never
|
||||||
|
/// reads the PLC. It produces the plan; <see cref="AbCipDriver"/> executes it.</para>
|
||||||
|
///
|
||||||
|
/// <para>The planner is intentionally <c>libplctag</c>-agnostic: the output is just a list of
|
||||||
|
/// <see cref="AbCipMultiPacketReadBatch"/> records that name the parent UDT, the per-member
|
||||||
|
/// read targets, and their byte offsets. The runtime layer decides whether to issue one
|
||||||
|
/// libplctag read per member (today's wrapper-limited fallback) or to flush the batch onto
|
||||||
|
/// one Multi-Service Packet (a future wrapper release). Either way the planner-tier logic
|
||||||
|
/// stays correct, which is why the unit tests in
|
||||||
|
/// <c>AbCipMultiPacketReadPlannerTests</c> assert plan shape rather than wire bytes.</para>
|
||||||
|
///
|
||||||
|
/// <para>Auto-mode dispatch (the heuristic): callers run <see cref="ChooseStrategyForGroup"/>
|
||||||
|
/// for each parent UDT to pick between the WholeUdt and MultiPacket paths per-group. The
|
||||||
|
/// heuristic divides <c>subscribedMembers / totalMembers</c> and picks MultiPacket when the
|
||||||
|
/// fraction is strictly less than the device's
|
||||||
|
/// <see cref="AbCipDeviceOptions.MultiPacketSparsityThreshold"/>.</para>
|
||||||
|
/// </remarks>
|
||||||
|
public static class AbCipMultiPacketReadPlanner
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Build a multi-packet read plan from <paramref name="requests"/>. Members of the same
|
||||||
|
/// parent UDT collapse into one <see cref="AbCipMultiPacketReadBatch"/>; references that
|
||||||
|
/// don't resolve to a UDT member fall back to <see cref="AbCipUdtReadFallback"/> for the
|
||||||
|
/// existing per-tag read path.
|
||||||
|
/// </summary>
|
||||||
|
public static AbCipMultiPacketReadPlan Build(
|
||||||
|
IReadOnlyList<string> requests,
|
||||||
|
IReadOnlyDictionary<string, AbCipTagDefinition> tagsByName)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(requests);
|
||||||
|
ArgumentNullException.ThrowIfNull(tagsByName);
|
||||||
|
|
||||||
|
var fallback = new List<AbCipUdtReadFallback>(requests.Count);
|
||||||
|
var byParent = new Dictionary<string, List<AbCipUdtReadMember>>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
for (var i = 0; i < requests.Count; i++)
|
||||||
|
{
|
||||||
|
var name = requests[i];
|
||||||
|
if (!tagsByName.TryGetValue(name, out var def))
|
||||||
|
{
|
||||||
|
fallback.Add(new AbCipUdtReadFallback(i, name));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var (parentName, memberName) = SplitParentMember(name);
|
||||||
|
if (parentName is null || memberName is null
|
||||||
|
|| !tagsByName.TryGetValue(parentName, out var parent)
|
||||||
|
|| parent.DataType != AbCipDataType.Structure
|
||||||
|
|| parent.Members is not { Count: > 0 })
|
||||||
|
{
|
||||||
|
fallback.Add(new AbCipUdtReadFallback(i, name));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var offsets = AbCipUdtMemberLayout.TryBuild(parent.Members);
|
||||||
|
if (offsets is null || !offsets.TryGetValue(memberName, out var offset))
|
||||||
|
{
|
||||||
|
fallback.Add(new AbCipUdtReadFallback(i, name));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!byParent.TryGetValue(parentName, out var members))
|
||||||
|
{
|
||||||
|
members = new List<AbCipUdtReadMember>();
|
||||||
|
byParent[parentName] = members;
|
||||||
|
}
|
||||||
|
members.Add(new AbCipUdtReadMember(i, def, offset));
|
||||||
|
}
|
||||||
|
|
||||||
|
var batches = new List<AbCipMultiPacketReadBatch>(byParent.Count);
|
||||||
|
foreach (var (parentName, members) in byParent)
|
||||||
|
{
|
||||||
|
batches.Add(new AbCipMultiPacketReadBatch(parentName, tagsByName[parentName], members));
|
||||||
|
}
|
||||||
|
|
||||||
|
return new AbCipMultiPacketReadPlan(batches, fallback);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// PR abcip-3.3 — Auto-mode heuristic. For a single parent UDT group with
|
||||||
|
/// <paramref name="subscribedMembers"/> of <paramref name="totalMembers"/> declared
|
||||||
|
/// members, pick <see cref="ReadStrategy.MultiPacket"/> when sparsity is strictly below
|
||||||
|
/// <paramref name="threshold"/>, else <see cref="ReadStrategy.WholeUdt"/>. Threshold is
|
||||||
|
/// clamped to <c>[0..1]</c>; out-of-range values saturate. Edge cases:
|
||||||
|
/// <c>totalMembers == 0</c> defaults to <see cref="ReadStrategy.WholeUdt"/> (the
|
||||||
|
/// historical behaviour) so a misconfigured tag map doesn't fault the read.
|
||||||
|
/// </summary>
|
||||||
|
public static ReadStrategy ChooseStrategyForGroup(int subscribedMembers, int totalMembers, double threshold)
|
||||||
|
{
|
||||||
|
if (totalMembers <= 0) return ReadStrategy.WholeUdt;
|
||||||
|
|
||||||
|
// Saturate the threshold to a sane range. 0.0 → never MultiPacket; 1.0 → always
|
||||||
|
// MultiPacket whenever any member is subscribed (deterministic boundary behaviour).
|
||||||
|
var t = threshold;
|
||||||
|
if (t < 0.0) t = 0.0;
|
||||||
|
if (t > 1.0) t = 1.0;
|
||||||
|
|
||||||
|
var fraction = (double)subscribedMembers / totalMembers;
|
||||||
|
return fraction < t ? ReadStrategy.MultiPacket : ReadStrategy.WholeUdt;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static (string? Parent, string? Member) SplitParentMember(string reference)
|
||||||
|
{
|
||||||
|
var dot = reference.IndexOf('.');
|
||||||
|
if (dot <= 0 || dot == reference.Length - 1) return (null, null);
|
||||||
|
return (reference[..dot], reference[(dot + 1)..]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>A planner output: per-parent multi-packet batches + per-tag fallbacks.</summary>
|
||||||
|
public sealed record AbCipMultiPacketReadPlan(
|
||||||
|
IReadOnlyList<AbCipMultiPacketReadBatch> Batches,
|
||||||
|
IReadOnlyList<AbCipUdtReadFallback> Fallbacks);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// One UDT parent whose subscribed members are bundled into a Multi-Service Packet read.
|
||||||
|
/// Reuses <see cref="AbCipUdtReadMember"/> from the WholeUdt planner so callers can decode
|
||||||
|
/// the member offsets uniformly across both planners.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record AbCipMultiPacketReadBatch(
|
||||||
|
string ParentName,
|
||||||
|
AbCipTagDefinition ParentDefinition,
|
||||||
|
IReadOnlyList<AbCipUdtReadMember> Members);
|
||||||
@@ -26,6 +26,7 @@
|
|||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<InternalsVisibleTo Include="ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests"/>
|
<InternalsVisibleTo Include="ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests"/>
|
||||||
|
<InternalsVisibleTo Include="ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests"/>
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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 (<see cref="AbCipDriver.DeviceState.MultiPacketGroupsExecuted"/>
|
||||||
|
/// counter increments) and returns Good StatusCodes for every member.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para><b>Required Emulate project state</b> (see <c>LogixProject/README.md</c> for
|
||||||
|
/// the L5X export that seeds this; ship the project once Emulate is on the integration
|
||||||
|
/// host):</para>
|
||||||
|
/// <list type="bullet">
|
||||||
|
/// <item>UDT <c>Tank_50</c> with 50 DINT members <c>M0</c>..<c>M49</c> — a deliberately
|
||||||
|
/// oversized UDT so a 5-member subscription is sparse enough for the
|
||||||
|
/// <see cref="AbCipDeviceOptions.MultiPacketSparsityThreshold"/> default of 0.25 to
|
||||||
|
/// pick MultiPacket.</item>
|
||||||
|
/// <item>Controller-scope tag <c>Tank1 : Tank_50</c> with each <c>M{i}</c> seeded to
|
||||||
|
/// <c>i * 10</c> so each subscribed member returns a distinct value.</item>
|
||||||
|
/// </list>
|
||||||
|
/// <para>Runs only when <c>AB_SERVER_PROFILE=emulate</c>. 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
|
||||||
|
/// <c>docs/drivers/AbCip-Performance.md</c> §"Read strategy").</para>
|
||||||
|
/// </remarks>
|
||||||
|
[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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// PR abcip-3.3 — coverage for the per-device <see cref="ReadStrategy"/> selector. Three
|
||||||
|
/// resolution layers under test: (a) <see cref="AbCipDriver.ResolveReadStrategy"/> at
|
||||||
|
/// device init (MultiPacket-against-Micro800 fall-back, plain pass-through otherwise),
|
||||||
|
/// (b) <see cref="AbCipMultiPacketReadPlanner.ChooseStrategyForGroup"/> sparsity heuristic
|
||||||
|
/// (Auto-mode dispatch), (c) end-to-end <see cref="AbCipDriver.ReadAsync"/> dispatch
|
||||||
|
/// verified by the per-device WholeUdt / MultiPacket counters.
|
||||||
|
/// </summary>
|
||||||
|
[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<string>();
|
||||||
|
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<string>();
|
||||||
|
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<string>();
|
||||||
|
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<string, AbCipTagDefinition>(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<string, AbCipTagDefinition>(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();
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user