diff --git a/MainWindow.IoTesting.AutoConnect.cs b/MainWindow.IoTesting.AutoConnect.cs index 30d7681cf..bdb4e163b 100644 --- a/MainWindow.IoTesting.AutoConnect.cs +++ b/MainWindow.IoTesting.AutoConnect.cs @@ -133,6 +133,17 @@ void ReportProgress(string message) ReportProgress($"{ied.IedName} association ready ยท reusing the loaded model"); } + // Never let a runtime anchor from an earlier model silently decide a fresh + // FAT preparation. Re-prove every requested row against the current model; + // successful smart matches are anchored again immediately below. + foreach (var point in requestedPoints) + { + point.ApplyLiveBinding( + IoTestLiveBindingState.NotEvaluated, + "Revalidating the imported FAT reference against the current IED model.", + device.DeviceId); + } + ReportProgress($"Matching {requestedPoints.Count} workbook signal(s)"); var selection = _ioTestSignalSelectionService.Resolve(ied, device); if (!selection.Succeeded && selection.CanRetryWithFreshDiscovery) @@ -150,6 +161,13 @@ void ReportProgress(string message) } usedSavedModel = false; + foreach (var point in requestedPoints) + { + point.ApplyLiveBinding( + IoTestLiveBindingState.NotEvaluated, + "Revalidating after fresh live-model discovery.", + device.DeviceId); + } selection = _ioTestSignalSelectionService.Resolve(ied, device); } @@ -163,6 +181,19 @@ void ReportProgress(string message) var selectionChanged = false; foreach (var match in selection.Matches) { + // Preserve the unique reference proven by the preparation pass. This is + // essential for legacy weak rows such as `.Op.general`: the live-binding + // phase must follow the proven exact model object rather than re-guess it. + match.TestPoint.ApplyLiveBinding( + match.UsedNormalizedIedPrefix + ? IoTestLiveBindingState.BoundNormalized + : IoTestLiveBindingState.BoundExact, + match.UsedNormalizedIedPrefix + ? "FAT preparation resolved one unique canonical IEC 61850 model reference." + : "FAT preparation matched the exact imported IEC 61850 model reference.", + device.DeviceId, + match.Signal.ObjectReference); + if (match.Signal.IsSelected) continue; match.Signal.IsSelected = true; diff --git a/Services/IoTesting/IoTestLiveBindingService.cs b/Services/IoTesting/IoTestLiveBindingService.cs index 965686195..63c6f9266 100644 --- a/Services/IoTesting/IoTestLiveBindingService.cs +++ b/Services/IoTesting/IoTestLiveBindingService.cs @@ -111,7 +111,7 @@ private static PointBinding BindPoint(IoTestPointPlan point, Iec61850MonitorDevi { return new PointBinding( IoTestLiveBindingState.LivePointReady, - "Exact imported IEC 61850 reference is already active in the live monitor.", + "Exact imported or prepared IEC 61850 reference is already active in the live monitor.", exactLivePoints[0].IecReference, exactLivePoints[0]); } @@ -124,7 +124,7 @@ private static PointBinding BindPoint(IoTestPointPlan point, Iec61850MonitorDevi { return new PointBinding( IoTestLiveBindingState.BoundExact, - "Exact imported IEC 61850 reference is present in the discovered IED model.", + "Exact imported or prepared IEC 61850 reference is present in the discovered IED model.", exactSignals[0].ObjectReference, null); } @@ -224,6 +224,14 @@ void Add(string? value) Add(point.SourceIecReference); Add(point.ReportDisplayReference); + // During FAT preparation the signal-selection pass may prove one unique live + // model reference from otherwise incomplete source metadata (for example a + // legacy 7SX80 ANSI-27 row). Keep that exact prepared reference authoritative + // for subsequent model/live-point binding. It is transient runtime state and is + // cleared automatically whenever ApplyLiveBinding reports a non-bound result. + if (point.IsLiveBound) + Add(point.LiveSignalReference); + var eventReference = !string.IsNullOrWhiteSpace(point.EventLogSearchReference) ? point.EventLogSearchReference.Trim() : point.SourceIecReference?.Trim() ?? string.Empty; diff --git a/Services/IoTesting/IoTestSignalSelectionService.cs b/Services/IoTesting/IoTestSignalSelectionService.cs index 08747a50f..b37090afe 100644 --- a/Services/IoTesting/IoTestSignalSelectionService.cs +++ b/Services/IoTesting/IoTestSignalSelectionService.cs @@ -1,3 +1,4 @@ +using System.Text.RegularExpressions; using ArIED61850Tester.Models; using ArIED61850Tester.Models.IoTesting; @@ -22,9 +23,15 @@ public sealed record IoTestSignalSelectionResult( /// Resolves the enabled IO-list scope against one discovered IED model without /// guessing. Exact references remain highest priority. Canonical IEC 61850 forms /// accept vendor-safe spelling differences only when the best candidate is unique. +/// Weak source rows are resolved only after stronger references have claimed their +/// signals, allowing deterministic sibling evidence without fuzzy text matching. /// public sealed class IoTestSignalSelectionService { + private static readonly Regex ProtectionCodeRegex = new( + @"\((?\d{2,3}[A-Z]{0,3})(?:\s*-\s*[^)]*)?\)", + RegexOptions.IgnoreCase | RegexOptions.CultureInvariant | RegexOptions.Compiled); + public IoTestSignalSelectionResult Resolve( IoTestIedPlan ied, Iec61850MonitorDevice device) @@ -39,6 +46,7 @@ public IoTestSignalSelectionResult Resolve( var missing = new List(); var ambiguous = new List(); var usedSignals = new HashSet(); + var unresolved = new List(); foreach (var point in requested) { @@ -70,19 +78,47 @@ public IoTestSignalSelectionResult Resolve( .Where(item => item.Score == bestScore) .Select(item => item.Signal) .ToList(); + unresolved.Add(new CandidateSet(point, bestScore, candidates)); + } - if (candidates.Count != 1 || !usedSignals.Add(candidates[0])) + // Resolve the strongest references first. This makes the result independent of + // workbook row order and lets a weak legacy row use already-proven sibling + // assignments as elimination evidence. No candidate is ever selected by text + // similarity: it must still be the one unique best IEC object left. + var madeProgress = true; + while (madeProgress && unresolved.Count > 0) + { + madeProgress = false; + foreach (var candidateSet in unresolved + .OrderByDescending(item => item.BestScore) + .ThenBy(item => item.Candidates.Count) + .ToArray()) { - ambiguous.Add(point); - continue; - } + var candidates = candidateSet.Candidates + .Where(signal => !usedSignals.Contains(signal)) + .ToList(); - matches.Add(new IoTestSignalMatch( - point, - candidates[0], - bestScore < IoTestReferenceMatcher.ExactScore)); + if (candidateSet.BestScore <= IoTestReferenceMatcher.PartialObjectScore && candidates.Count > 1) + candidates = NarrowByProtectionIdentity(candidateSet.Point, candidates); + + if (candidates.Count != 1) + continue; + + var signal = candidates[0]; + if (!usedSignals.Add(signal)) + continue; + + matches.Add(new IoTestSignalMatch( + candidateSet.Point, + signal, + candidateSet.BestScore < IoTestReferenceMatcher.ExactScore)); + unresolved.Remove(candidateSet); + madeProgress = true; + } } + ambiguous.AddRange(unresolved.Select(item => item.Point)); + if (missing.Count > 0 || ambiguous.Count > 0) { var details = new List(); @@ -108,6 +144,41 @@ public IoTestSignalSelectionResult Resolve( $"Resolved {matches.Count} enabled IO-list signal(s) to unique discovered model points.{smartText}"); } + private static List NarrowByProtectionIdentity( + IoTestPointPlan point, + IReadOnlyCollection candidates) + { + var match = ProtectionCodeRegex.Match(point.SignalName ?? string.Empty); + if (!match.Success) + return candidates.ToList(); + + // P0 field regression: a few legacy 7SX80 rows retained only `.Op.general` + // while the user-visible signal description still proved ANSI 27. Do not turn + // this into a generic fuzzy-name matcher. For ANSI 27 only, accept the explicit + // live IEC identity used by protection models (27Undervoltage / PTUV), and still + // require one unique candidate after stronger sibling references are claimed. + var code = match.Groups["code"].Value.ToUpperInvariant(); + if (!code.Equals("27", StringComparison.Ordinal)) + return candidates.ToList(); + + var narrowed = candidates + .Where(signal => IsProtection27Reference(signal.ObjectReference)) + .ToList(); + return narrowed.Count == 0 ? candidates.ToList() : narrowed; + } + + private static bool IsProtection27Reference(string? reference) + { + var normalized = IoTestReferenceMatcher.NormalizeRaw(reference); + if (normalized.Contains("27undervoltage", StringComparison.OrdinalIgnoreCase)) + return true; + + return Regex.IsMatch( + normalized, + @"(?:^|[/_.])ptuv\d*(?:[/.]|$)", + RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); + } + private static bool IsEligible(SignalDefinition signal, IoTestPointPlan point) { if (signal.IsControlSignal || string.IsNullOrWhiteSpace(signal.ObjectReference)) @@ -130,4 +201,8 @@ private static string Describe(IReadOnlyCollection points) } private sealed record ScoredSignal(SignalDefinition Signal, int Score); + private sealed record CandidateSet( + IoTestPointPlan Point, + int BestScore, + IReadOnlyList Candidates); } diff --git a/tests/ARSAS.Tests/IoFatProtection27RegressionTests.cs b/tests/ARSAS.Tests/IoFatProtection27RegressionTests.cs new file mode 100644 index 000000000..9938ced24 --- /dev/null +++ b/tests/ARSAS.Tests/IoFatProtection27RegressionTests.cs @@ -0,0 +1,246 @@ +using ArIED61850Tester.Models; +using ArIED61850Tester.Models.IoTesting; +using ArIED61850Tester.Services.IoTesting; + +namespace ARSAS.Tests; + +public sealed class IoFatProtection27RegressionTests +{ + private readonly IoTestSignalSelectionService _selection = new(); + private readonly IoTestLiveBindingService _binding = new(); + + [Fact] + public void SevenSx80Protection27_CompleteWorkbookReference_MatchesCorrectPtrcAmongSiblingFunctions() + { + var point = CompleteProtection27Point( + "UCC-IEC-0041", + "Protection operated (27)", + "AA1C1F03R3Application/VI3p1_27Undervoltage1/PTRC1.Op.general", + "VI3p1_27Undervoltage1/PTRC1.Op"); + var ied = Ied("AA1C1F03R3", "192.168.81.69", point); + var device = Device( + "AA1C1F03R3", + "192.168.81.69", + Signal("Protection operated (67)", "AA1C1F03R3VI3p1_67DirOC3phB1/PTRC1$ST$Op$general"), + Signal("Protection operated (59)", "AA1C1F03R3VI3p1_59Overvoltage1/PTRC1$ST$Op$general"), + Signal("Protection operated (27)", "AA1C1F03R3VI3p1_27Undervoltage1/PTRC1$ST$Op$general")); + + var result = _selection.Resolve(ied, device); + + Assert.True(result.Succeeded, result.Message); + var match = Assert.Single(result.Matches); + Assert.Equal( + "AA1C1F03R3VI3p1_27Undervoltage1/PTRC1$ST$Op$general", + match.Signal.ObjectReference); + // Event-log reference + DA reconstructs the exact MMS identity, so this + // intentionally wins at ExactScore rather than being reported as a normalized fallback. + Assert.False(match.UsedNormalizedIedPrefix); + } + + [Fact] + public void SevenSx80Protection27_ApplicationFolderVariant_MatchesConcatenatedLiveLnForm() + { + var point = CompleteProtection27Point( + "UCC-IEC-0041", + "Protection operated (27)", + "AA1C1F03R3Application/VI3p1_27Undervoltage1/PTRC1.Op.general", + "VI3p1_27Undervoltage1/PTRC1.Op"); + var ied = Ied("AA1C1F03R3", "192.168.81.69", point); + var device = Device( + "AA1C1F03R3", + "192.168.81.69", + Signal( + "Protection operated (27)", + "AA1C1F03R3Application/VI3p1_27Undervoltage1PTRC1$ST$Op$general")); + + var result = _selection.Resolve(ied, device); + + Assert.True(result.Succeeded, result.Message); + Assert.Single(result.Matches); + } + + [Fact] + public void SevenSx80Protection27_Weak27FirstRow_UsesStrongSiblingAsSafeEliminationEvidence() + { + // Field case: 27-1 lost its LD/LN path and arrived as `.Op.general`, while + // 27-2 still retained its full VI1p1_27Undervoltage1/PTRC1 reference. Keep the + // weak row first to prove matching is no longer dependent on workbook row order. + var weak27First = WeakProtection27Point( + "UCC-IEC-0733", + "Protection operated (27-1)"); + var strong27Second = CompleteProtection27Point( + "UCC-IEC-0734", + "Protection operated (27-2)", + "AA1C1F13R1Application/VI1p1_27Undervoltage1/PTRC1.Op.general", + "VI1p1_27Undervoltage1/PTRC1.Op"); + var ied = Ied("AA1C1F13R1", "192.168.81.14", weak27First, strong27Second); + var device = Device( + "AA1C1F13R1", + "192.168.81.14", + Signal("Protection operated (51)", "AA1C1F13R1VI3p1_5051OC3phase1/II_PTOC1$ST$Op$general"), + Signal("Protection operated (27-1)", "AA1C1F13R1VI3p1_27Undervoltage1/PTRC1$ST$Op$general"), + Signal("Protection operated (27-2)", "AA1C1F13R1VI1p1_27Undervoltage1/PTRC1$ST$Op$general")); + + var result = _selection.Resolve(ied, device); + + Assert.True(result.Succeeded, result.Message); + Assert.Equal(2, result.Matches.Count); + Assert.Equal( + "AA1C1F13R1VI3p1_27Undervoltage1/PTRC1$ST$Op$general", + result.Matches.Single(match => ReferenceEquals(match.TestPoint, weak27First)).Signal.ObjectReference); + Assert.Equal( + "AA1C1F13R1VI1p1_27Undervoltage1/PTRC1$ST$Op$general", + result.Matches.Single(match => ReferenceEquals(match.TestPoint, strong27Second)).Signal.ObjectReference); + } + + [Fact] + public void SevenSx80Protection27_WeakReferenceWithoutSiblingProof_RemainsAmbiguous() + { + var weak27 = WeakProtection27Point( + "UCC-IEC-0733", + "Protection operated (27-1)"); + var ied = Ied("AA1C1F13R1", "192.168.81.14", weak27); + var device = Device( + "AA1C1F13R1", + "192.168.81.14", + Signal("Protection operated (27 A)", "AA1C1F13R1VI3p1_27Undervoltage1/PTRC1$ST$Op$general"), + Signal("Protection operated (27 B)", "AA1C1F13R1VI1p1_27Undervoltage1/PTRC1$ST$Op$general")); + + var result = _selection.Resolve(ied, device); + + Assert.False(result.Succeeded); + Assert.Empty(result.MissingPoints); + Assert.Single(result.AmbiguousPoints); + } + + [Fact] + public void PreparedProtection27Reference_RemainsAuthoritativeDuringLiveBinding() + { + var weak27First = WeakProtection27Point( + "UCC-IEC-0733", + "Protection operated (27-1)"); + var strong27Second = CompleteProtection27Point( + "UCC-IEC-0734", + "Protection operated (27-2)", + "AA1C1F13R1Application/VI1p1_27Undervoltage1/PTRC1.Op.general", + "VI1p1_27Undervoltage1/PTRC1.Op"); + var ied = Ied("AA1C1F13R1", "192.168.81.14", weak27First, strong27Second); + var project = new IoTestProject + { + ProjectId = "CCPP-P0-27", + SchemaVersion = "ARSAS-FAT-IO-1.0", + ProjectName = "Protection 27 regression", + Ieds = { ied } + }; + project.InitializeRuntimeNotifications(); + var device = Device( + "AA1C1F13R1", + "192.168.81.14", + Signal("Protection operated (51)", "AA1C1F13R1VI3p1_5051OC3phase1/II_PTOC1$ST$Op$general"), + Signal("Protection operated (27-1)", "AA1C1F13R1VI3p1_27Undervoltage1/PTRC1$ST$Op$general"), + Signal("Protection operated (27-2)", "AA1C1F13R1VI1p1_27Undervoltage1/PTRC1$ST$Op$general")); + + var selection = _selection.Resolve(ied, device); + Assert.True(selection.Succeeded, selection.Message); + foreach (var match in selection.Matches) + { + match.TestPoint.ApplyLiveBinding( + IoTestLiveBindingState.BoundNormalized, + "Prepared by regression test", + device.DeviceId, + match.Signal.ObjectReference); + } + + var summary = _binding.Bind(project, new[] { device }); + + Assert.Equal(2, summary.SignalBoundCount); + Assert.Equal(0, summary.MissingSignalCount); + Assert.Equal( + "AA1C1F13R1VI3p1_27Undervoltage1/PTRC1$ST$Op$general", + weak27First.LiveSignalReference); + Assert.True(weak27First.IsLiveBound); + } + + private static IoTestPointPlan CompleteProtection27Point( + string id, + string signalName, + string objectReference, + string eventReference) => new() + { + TestPointId = id, + IedName = objectReference.StartsWith("AA1C1F13R1", StringComparison.OrdinalIgnoreCase) + ? "AA1C1F13R1" + : "AA1C1F03R3", + IpAddress = objectReference.StartsWith("AA1C1F13R1", StringComparison.OrdinalIgnoreCase) + ? "192.168.81.14" + : "192.168.81.69", + SignalName = signalName, + ObjectReference = objectReference, + LogicalDevice = objectReference.StartsWith("AA1C1F13R1", StringComparison.OrdinalIgnoreCase) + ? "AA1C1F13R1Application" + : "AA1C1F03R3Application", + LogicalNode = "PTRC1", + DataObject = "Op", + DataAttribute = "general", + FunctionalConstraint = "ST", + EventLogSearchReference = eventReference, + SourceIecReference = eventReference, + ReportDisplayReference = objectReference + " [ST]", + ExpectedOnText = "Operated", + ExpectedOffText = "Normal", + ImportReady = true, + TestEnabled = true + }; + + private static IoTestPointPlan WeakProtection27Point(string id, string signalName) => new() + { + TestPointId = id, + IedName = "AA1C1F13R1", + IpAddress = "192.168.81.14", + SignalName = signalName, + ObjectReference = ".Op.general", + LogicalDevice = "AA1C1F13R1Application", + LogicalNode = "-", + DataObject = ".Op", + DataAttribute = "general", + FunctionalConstraint = "ST", + EventLogSearchReference = ".Op", + SourceIecReference = ".Op", + ReportDisplayReference = "AA1C1F13R1Application/.Op.general [ST]", + ExpectedOnText = "Operated", + ExpectedOffText = "Normal", + ImportReady = true, + TestEnabled = true + }; + + private static IoTestIedPlan Ied(string name, string ipAddress, params IoTestPointPlan[] points) => new() + { + IedName = name, + IpAddress = ipAddress, + IedRole = "IED - 7SX80", + TestPoints = points.ToList() + }; + + private static Iec61850MonitorDevice Device( + string name, + string ipAddress, + params SignalDefinition[] signals) + { + var device = new Iec61850MonitorDevice + { + Name = name, + SclIedName = name, + IpAddress = ipAddress, + Port = 102 + }; + device.Signals.AddRange(signals); + return device; + } + + private static SignalDefinition Signal(string name, string reference) => new() + { + Name = name, + ObjectReference = reference, + FunctionalConstraint = "ST" + }; +}