diff --git a/MainWindow.IedBrowser.cs b/MainWindow.IedBrowser.cs index fd277a8c7..44275a068 100644 --- a/MainWindow.IedBrowser.cs +++ b/MainWindow.IedBrowser.cs @@ -733,18 +733,15 @@ private void UpdateIedBrowserControlStateDetails() } else { - var target = IedBrowserControlStateEvidence.ReadTargets(block, pageKind) - .FirstOrDefault(item => item.Reference.EndsWith( - "." + field, StringComparison.Ordinal)); - if (target is not null && - _iedBrowserReadFailureEvidence?.TryGet(device, - target.Reference, "", target.FunctionalConstraint, - out var failure) == true) + if (IedBrowserControlStateEvidence.TryReadFailure( + device, block, pageKind, field, + _iedBrowserReadFailureEvidence, device.InitialHydrationFailures, + out var failure, out var source, out var failedReference)) { row.Value = "Unavailable"; - row.ObservationSource = "MMS Read failed"; + row.ObservationSource = source; row.ObservationTooltip = - $"{target.Reference} [{target.FunctionalConstraint}] · {failure}"; + $"{failedReference} [{block.FunctionalConstraint}] · {source} · {failure}"; } else { diff --git a/Models/IedBrowserControlStateEvidence.cs b/Models/IedBrowserControlStateEvidence.cs index c50073eea..cbaae22d8 100644 --- a/Models/IedBrowserControlStateEvidence.cs +++ b/Models/IedBrowserControlStateEvidence.cs @@ -84,6 +84,42 @@ public static bool TryRead( } return true; } + /// + /// Error-only status for exactly the verified SGCB / GoCB MMS leaf. + /// Manual Read's latest attempt takes precedence over initial hydration; + /// neither failure is allowed to claim a live value or new FC. + /// The consumer checks LiveValues success before calling this method. + /// + public static bool TryReadFailure( + Iec61850MonitorDevice device, LiveIedControlBlockModel block, + string pageKind, string field, + IedBrowserReadFailureEvidence? manual, + IedBrowserReadFailureEvidence? initial, + out string reason, out string source, out string exactReference) + { + reason = ""; + source = ""; + exactReference = ""; + var target = ReadTargets(block, pageKind).FirstOrDefault(item => + item.Reference.EndsWith("." + field, StringComparison.Ordinal)); + if (target is null) return false; + exactReference = target.Reference; + if (manual?.TryGet(device, target.Reference, "", + target.FunctionalConstraint, out reason) == true) + { + source = "MMS manual Read failed"; + return true; + } + if (initial?.TryGet(device, target.Reference, "", + target.FunctionalConstraint, out reason) == true) + { + source = "MMS initial Read failed"; + return true; + } + reason = ""; + return false; + } + /// /// SGCB LActTm is a real relay value, but an all-zero wire UTC-Time /// denotes an unset time rather than an actual group-change in 1970. diff --git a/Models/IedBrowserReadFailureEvidence.cs b/Models/IedBrowserReadFailureEvidence.cs index 696b24f9a..65f70cc9c 100644 --- a/Models/IedBrowserReadFailureEvidence.cs +++ b/Models/IedBrowserReadFailureEvidence.cs @@ -9,6 +9,9 @@ namespace ArIED61850Tester.Models; /// conversion, retry or live state. Exact reference/FC, same IED object, /// association and projection epoch are required on every UI lookup. /// +public sealed record IedBrowserReadFailureSample( + string Reference, string FunctionalConstraint, string Reason); + public sealed class IedBrowserReadFailureEvidence { private const int MaxFailureEntries = 4096; @@ -68,6 +71,32 @@ public void ClearFailure(IedBrowserReadTarget target) _failed.Remove(Key(target.Reference, target.FunctionalConstraint)); } + /// + /// Bounded immutable sample of error-only evidence from the current IED, + /// association and verified model/epoch. No process values or MMS IO. + /// + public IReadOnlyList SnapshotCurrent( + Iec61850MonitorDevice device, int maxEntries = 12) + { + if (maxEntries <= 0 || !IsCurrent(device)) + return Array.Empty(); + lock (_gate) + { + if (!IsCurrent(device)) + return Array.Empty(); + return _failed.OrderBy(item => item.Key, StringComparer.Ordinal) + .Take(Math.Min(maxEntries, 32)) + .Select(item => + { + var separator = item.Key.LastIndexOf('|'); + return new IedBrowserReadFailureSample( + item.Key[..separator], item.Key[(separator + 1)..], + item.Value); + }) + .ToArray(); + } + } + public bool TryGet(Iec61850MonitorDevice device, string reference, string fallbackReference, string fc, out string reason) { diff --git a/Services/DiagnosticReportBuilder.cs b/Services/DiagnosticReportBuilder.cs index fd67a132c..215cad91f 100644 --- a/Services/DiagnosticReportBuilder.cs +++ b/Services/DiagnosticReportBuilder.cs @@ -166,6 +166,18 @@ public static async Task BuildAsync( } builder.AppendLine($"Connected : {device.IsConnected}"); builder.AppendLine($"Monitoring : {device.IsMonitoring}"); + // Diagnostic projection only: no MMS reads, new caches, or + // synthetic values. Samples are scoped to the active IED. + builder.AppendLine($"Browser ledger : association={device.LiveValues.Association}, version={device.LiveValues.Version}, offline={device.LiveValues.IsOffline}"); + var hydrationFailures = device.InitialHydrationFailures? + .SnapshotCurrent(device, maxEntries: 12) ?? + Array.Empty(); + builder.AppendLine($"Initial MMS errors (sample max 12): {hydrationFailures.Count}"); + foreach (var evidence in hydrationFailures) + { + var detail = evidence.Reason.Replace('\r', ' ').Replace('\n', ' '); + builder.AppendLine($" {evidence.Reference} [{evidence.FunctionalConstraint}] : {detail}"); + } builder.AppendLine($"Busy : {device.IsBusy}"); builder.AppendLine($"Status : {device.Status}"); builder.AppendLine($"Detail : {device.Detail}"); diff --git a/tests/ARSAS.Tests/M217DControlReadFailureDiagnosticsTests.cs b/tests/ARSAS.Tests/M217DControlReadFailureDiagnosticsTests.cs new file mode 100644 index 000000000..afecbcbc8 --- /dev/null +++ b/tests/ARSAS.Tests/M217DControlReadFailureDiagnosticsTests.cs @@ -0,0 +1,141 @@ +using AR.Iec61850.Discovery; +using ArIED61850Tester.Models; + +namespace ARSAS.Tests; + +public sealed class M217DControlReadFailureDiagnosticsTests +{ + private static LiveIedControlBlockModel Block(string kind = "SettingGroupControl", + string fc = "SP", string name = "SGCB", string status = "AttributeInventoryOnly") + => new() + { + Domain = "LD", LogicalNode = "LLN0", Name = name, + Kind = kind, FunctionalConstraint = fc, + DiscoveryStatus = status, + Reference = $"LD/LLN0.{fc}.{name}", + Attributes = kind == "SettingGroupControl" + ? ["NumOfSG", "ActSG", "LActTm", "EditSG"] + : ["GoEna", "NdsCom"] + }; + + private static (Iec61850MonitorDevice Device, IedBrowserReadFailureEvidence Initial) + Connected() + { + var model = new LiveIedModelDiscoveryDocument + { + LogicalDevices = [new LiveIedLogicalDeviceModel { MmsDomain = "LD" }] + }; + var device = new Iec61850MonitorDevice + { + LiveDiscoveryModel = model, IsConnected = true + }; + return (device, new IedBrowserReadFailureEvidence( + device, bindToLiveModel: true)); + } + + [Fact] + public void VerifiedSgcbInitialReadErrorIsSurfacedWithoutInventingValue() + { + var (device, initial) = Connected(); + var block = Block(); + initial.RecordFailure(new IedBrowserReadTarget( + "LD/LLN0$SP$SGCB$ActSG", "SP", "UInt32"), "data-access-error: object-non-existent"); + + Assert.True(IedBrowserControlStateEvidence.TryReadFailure(device, + block, "SGCB", "ActSG", null, initial, + out var reason, out var origin, out var reference)); + Assert.Equal("MMS initial Read failed", origin); + Assert.Equal("LD/LLN0.SGCB.ActSG", reference); + Assert.Contains("object-non-existent", reason); + Assert.False(device.LiveValues.TryGet(reference, "SP", out _)); + Assert.False(IedBrowserControlStateEvidence.TryReadFailure(device, + block, "SGCB", "EditSG", null, initial, out _, out _, out _)); + Assert.False(IedBrowserControlStateEvidence.TryReadFailure(device, + block, "SGCB", "ActSG", null, initial, out _, out _, out _) + && device.LiveValues.IsOffline); + } + + [Fact] + public void ManualFailureTakesPrecedenceThenFallsBackAfterCatalogReplacement() + { + var (device, initial) = Connected(); + var block = Block(); + var target = new IedBrowserReadTarget( + "LD/LLN0.SGCB.NumOfSG", "SP", "UInt32"); + initial.RecordFailure(target, "initial denied"); + var manual = new IedBrowserReadFailureEvidence(device); + manual.RecordFailure(target, "manual timeout"); + Assert.True(IedBrowserControlStateEvidence.TryReadFailure(device, + block, "SGCB", "NumOfSG", manual, initial, + out var reason, out var origin, out _)); + Assert.Equal("manual timeout", reason); + Assert.Equal("MMS manual Read failed", origin); + device.Signals.ReplaceAll([new SignalDefinition + { + ObjectReference = "LD/LLN0.Mod.stVal", FunctionalConstraint = "ST" + }]); + Assert.True(IedBrowserControlStateEvidence.TryReadFailure(device, + block, "SGCB", "NumOfSG", manual, initial, + out reason, out origin, out _)); + Assert.Equal("initial denied", reason); + Assert.Equal("MMS initial Read failed", origin); + } + + [Fact] + public void GoCbReadFailureIsNotGooseFrameEvidenceOrAnSclOnlyAssumption() + { + var (device, initial) = Connected(); + var block = Block(kind: "GSEControl", fc: "GO", name: "BCU"); + initial.RecordFailure(new IedBrowserReadTarget( + "LD/LLN0.BCU.GoEna", "GO", "Boolean"), "MMS reject"); + Assert.True(IedBrowserControlStateEvidence.TryReadFailure(device, + block, "GoCB", "GoEna", null, initial, + out _, out var source, out _)); + Assert.Equal("MMS initial Read failed", source); + Assert.False(IedBrowserControlStateEvidence.TryReadFailure(device, + block, "GoCB", "NdsCom", null, initial, out _, out _, out _)); + Assert.False(IedBrowserControlStateEvidence.TryReadFailure(device, + Block(kind: "GSEControl", fc: "GO", name: "BCU", status: "SCLOnly"), + "GoCB", "GoEna", null, initial, out _, out _, out _)); + } + + [Fact] + public void FailureDiagnosticSampleIsBoundedSortedAndOnlyCurrentAssociation() + { + var (device, initial) = Connected(); + for (var i = 40; i >= 1; i--) + initial.RecordFailure(new IedBrowserReadTarget( + $"LD/LLN0.Mod{i:000}.stVal", "ST", "Enum"), + "read error " + i); + var sample = initial.SnapshotCurrent(device, maxEntries: 200); + Assert.Equal(32, sample.Count); + Assert.Equal("LD/LLN0.Mod001.stVal", sample[0].Reference); + Assert.All(sample, s => Assert.Equal("ST", s.FunctionalConstraint)); + Assert.Empty(initial.SnapshotCurrent(device, maxEntries: 0)); + var (other, _) = Connected(); + Assert.Empty(initial.SnapshotCurrent(other)); + device.IsConnected = false; + Assert.Empty(initial.SnapshotCurrent(device)); + device.IsConnected = true; + Assert.Empty(initial.SnapshotCurrent(device)); + } + + [Fact] + public void DiagnosticsUseExistingFailureLedgerWithoutNetworkReadOrShadowCache() + { + DirectoryInfo? folder = new(AppContext.BaseDirectory); + while (folder is not null && + !File.Exists(Path.Combine(folder.FullName, "Services", "DiagnosticReportBuilder.cs"))) + folder = folder.Parent; + Assert.NotNull(folder); + var report = File.ReadAllText(Path.Combine(folder!.FullName, + "Services", "DiagnosticReportBuilder.cs")); + var browser = File.ReadAllText(Path.Combine(folder.FullName, + "MainWindow.IedBrowser.cs")); + Assert.Contains("SnapshotCurrent(device, maxEntries: 12)", report); + Assert.Contains("Browser ledger : association=", report); + Assert.Contains("Initial MMS errors (sample max 12)", report); + Assert.Contains("device.InitialHydrationFailures,", browser); + Assert.Contains("IedBrowserControlStateEvidence.TryReadFailure(", browser); + } +}