using ZB.MOM.WW.OtOpcUa.Driver.Ab.LibplctagShared; using System.Globalization; using Shouldly; using Xunit; namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential; /// /// Task 21 latency comparison: a fixed set of atomic tags polled through libplctag, then through the native /// transport, each pass issuing every read at once. Pass criterion: native p95 ≤ libplctag p95 × 1.10. /// Gated by the same variables as the differential (AB_DIFF_ENDPOINT absent ⇒ skip), in the same /// serialized collection, and read-only under the same source/IL guard /// (). It runs AFTER the /// differential's one run, reusing its browse for tag selection, so the target sees one listing per transport, /// not two. Knobs: AB_LAT_TAGS, AB_LAT_PASSES, AB_LAT_PASS_GAP_MS (see /// ). /// [Collection(DifferentialCollection.Name)] [Trait("Category", "LiveIntegration")] public sealed class PollLatencyComparisonTests(DifferentialRunFixture fixture) { private const double Tolerance = 1.10; private static readonly TimeSpan RunBudget = TimeSpan.FromMinutes(10); [Fact] public async Task Native_poll_p95_is_within_ten_percent_of_libplctag() { var target = DifferentialTarget.FromEnvironment(); if (target is null) Assert.Skip(DifferentialTarget.SkipReason); var differential = await fixture.GetAsync(); using var cts = new CancellationTokenSource(RunBudget); var r = await PollLatencyRun.ExecuteAsync(target, differential, cts.Token); Log($"## Latency — {r.Target}"); Log($"Tags per pass: {r.Tags.Count} ({r.DistinctTags} distinct" + (r.Repeated ? $", REPEATED cyclically to reach {DifferentialTarget.LatencyTagsVar}={r.Target.LatencyTags}" : "") + $"); passes per transport: {r.Target.LatencyPasses}; gap between passes: {r.Target.LatencyPassGapMs} ms."); Log("Types: " + string.Join(", ", r.Tags.GroupBy(t => t.Type).Select(g => $"{g.Key} {g.Count()}"))); if (r.Dropped.Count > 0) Log($"Dropped (libplctag refused at setup, removed from both sets): {string.Join(", ", r.Dropped)}"); Log($"Native requests spent on setup (one read per tag): {r.NativeSetupRequests}; native Forward Opens: {r.NativeForwardOpens}; " + $"native Forward Closes sent/acked: {r.NativeForwardCloses.Sent}/{r.NativeForwardCloses.Acked}; " + $"connections after the native phase: {r.ConnectionsAfterNative?.ToString(CultureInfo.InvariantCulture) ?? "unobservable"}."); Log($"libplctag orphans destroyed so far: {LibplctagOrphanRelease.Released}; rooted (unreleasable): {LibplctagOrphanRelease.RootedCount}."); Log(""); var libP50 = PollLatencyRun.Percentile(r.Libplctag.Select(p => p.Wall), 50); var libP95 = PollLatencyRun.Percentile(r.Libplctag.Select(p => p.Wall), 95); var natP50 = PollLatencyRun.Percentile(r.Native.Select(p => p.Wall), 50); var natP95 = PollLatencyRun.Percentile(r.Native.Select(p => p.Wall), 95); var limit = libP95 * Tolerance; Log("| Transport | Passes | p50 ms | p95 ms | min ms | max ms | CIP requests per pass (median) | Tag failures |"); Log("|---|---|---|---|---|---|---|---|"); Log(Row("libplctag", r.Libplctag, libP50, libP95)); Log(Row("native", r.Native, natP50, natP95)); Log(""); Log($"Criterion: native p95 {Ms(natP95)} ms ≤ libplctag p95 {Ms(libP95)} ms × {Tolerance:0.00} = {Ms(limit)} ms → " + (natP95 <= limit ? "PASS" : "**FAIL**")); Log(""); // Attribution (review r1 M8): GC collections during each pass (gen0/gen1/gen2) on both sides, and the // round trip of each native CIP exchange — a slow pass with fast exchanges is a client-side stall. Log("| Pass | libplctag ms | libplctag GC 0/1/2 | native ms | native GC 0/1/2 | native requests | native RTT min/mean/max ms |"); Log("|---|---|---|---|---|---|---|"); for (var i = 0; i < Math.Max(r.Libplctag.Count, r.Native.Count); i++) { var lib = i < r.Libplctag.Count ? r.Libplctag[i] : null; var nat = i < r.Native.Count ? r.Native[i] : null; Log($"| {i + 1} | {(lib is null ? "-" : Ms(lib.Wall.TotalMilliseconds))} | {lib?.Gc.ToString() ?? "-"} | " + $"{(nat is null ? "-" : Ms(nat.Wall.TotalMilliseconds))} | {nat?.Gc.ToString() ?? "-"} | " + $"{nat?.Requests?.ToString(CultureInfo.InvariantCulture) ?? "-"} | {Rtt(nat?.Rtt)} |"); } foreach (var p in r.Problems) Log($"- PROBLEM: {p}"); r.Problems.ShouldBeEmpty(); r.Libplctag.Count.ShouldBe(r.Target.LatencyPasses); r.Native.Count.ShouldBe(r.Target.LatencyPasses); r.Libplctag.Sum(p => p.TagFailures).ShouldBe(0, "every timed libplctag read must succeed"); r.Native.Sum(p => p.TagFailures).ShouldBe(0, "every timed native read must succeed"); if (r.Target.CountOpens) { r.NativeForwardOpens.ShouldBe(1); r.NativeForwardCloses.Sent.ShouldBe(1, "the native phase's one connection must be closed with exactly one Forward Close"); } if (r.ConnectionsAfterNative is { } after && differential.ConnectionsBaseline is { } baseline) after.ShouldBeLessThanOrEqualTo(baseline, "the native session must be gone when the run reports"); // Review r1 M4: the latency setup's refused libplctag tags are orphan candidates too — checked after them. LibplctagOrphanRelease.RootedCount.ShouldBe(0); natP95.ShouldBeLessThanOrEqualTo(limit, $"native p95 must be ≤ libplctag p95 × {Tolerance:0.00}"); } /// Offline: the tag pick takes atomic scalars only, and repeats a short set deliberately and visibly. [Fact] public void Tag_pick_takes_atomic_scalars_only_and_repeats_a_short_set_visibly() { var browsed = new TransportObservations(); browsed.Browse.Add(new AbCipDiscoveredTag("D", null, AbCipDataType.DInt, false)); browsed.Browse.Add(new AbCipDiscoveredTag("R", null, AbCipDataType.Real, false)); browsed.Browse.Add(new AbCipDiscoveredTag("S", null, AbCipDataType.String, false)); browsed.Browse.Add(new AbCipDiscoveredTag("T", null, AbCipDataType.Dt, false)); browsed.Browse.Add(new AbCipDiscoveredTag("A", null, AbCipDataType.DInt, false, ElementCount: 4, IsArray: true)); browsed.Browse.Add(new AbCipDiscoveredTag("Sys", null, AbCipDataType.DInt, false, IsSystemTag: true)); var target = new DifferentialTarget("127.0.0.1", 1, "1,0", AbCipPlcFamily.CompactLogix, null, 200, false, LatencyTags: 5); var (tags, distinct, repeated) = PollLatencyRun.PickTags(target, browsed); distinct.ShouldBe(2); repeated.ShouldBeTrue(); tags.Select(t => t.Name).ShouldBe(["D", "R", "D", "R", "D"]); var (one, oneDistinct, oneRepeated) = PollLatencyRun.PickTags(target with { LatencyTags = 1 }, browsed); one.ShouldHaveSingleItem().Name.ShouldBe("D"); oneDistinct.ShouldBe(1); oneRepeated.ShouldBeFalse(); PollLatencyRun.Percentile([TimeSpan.FromMilliseconds(3), TimeSpan.FromMilliseconds(1), TimeSpan.FromMilliseconds(2)], 50) .ShouldBe(2); PollLatencyRun.Percentile(Enumerable.Range(1, 20).Select(i => TimeSpan.FromMilliseconds(i)), 95).ShouldBe(19); } private static string Row(string name, IReadOnlyList passes, double p50, double p95) { var walls = passes.Select(p => p.Wall.TotalMilliseconds).ToList(); var requests = passes.Where(p => p.Requests is not null).Select(p => p.Requests!.Value).Order().ToList(); var medianRequests = requests.Count == 0 ? "n/a" : requests[requests.Count / 2].ToString(CultureInfo.InvariantCulture); return $"| {name} | {passes.Count} | {Ms(p50)} | {Ms(p95)} | {Ms(walls.Min())} | {Ms(walls.Max())} | {medianRequests} | " + $"{passes.Sum(p => p.TagFailures)} |"; } private static string Rtt(ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Cip.ConnectionDiagnostics.ExchangeStats? s) => s is not { Count: > 0 } x ? "-" : $"{Ms(x.MinMs)}/{Ms(x.MeanMs)}/{Ms(x.MaxMs)}"; private static string Ms(double ms) => ms.ToString("0.0", CultureInfo.InvariantCulture); private static void Log(string line) => TestContext.Current.TestOutputHelper?.WriteLine(line); }