diff --git a/Assets/ied-protection-relay-fascia.png b/Assets/ied-protection-relay-fascia.png index 8d47aee5..a709aaeb 100644 Binary files a/Assets/ied-protection-relay-fascia.png and b/Assets/ied-protection-relay-fascia.png differ diff --git a/Services/IoTesting/IoTestLiveBindingService.cs b/Services/IoTesting/IoTestLiveBindingService.cs index 7f419edb..cd15c784 100644 --- a/Services/IoTesting/IoTestLiveBindingService.cs +++ b/Services/IoTesting/IoTestLiveBindingService.cs @@ -104,7 +104,8 @@ private static PointBinding BindPoint(IoTestPointPlan point, Iec61850MonitorDevi .ToHashSet(StringComparer.OrdinalIgnoreCase); var exactLivePoints = device.Points - .Where(item => expectedReferences.Contains(NormalizeReference(item.IecReference))) + .Where(item => FunctionalConstraintMatches(point.FunctionalConstraint, item.FunctionalConstraint) && + expectedReferences.Contains(NormalizeReference(item.IecReference))) .ToList(); if (exactLivePoints.Count == 1) { @@ -117,6 +118,7 @@ private static PointBinding BindPoint(IoTestPointPlan point, Iec61850MonitorDevi var exactSignals = device.Signals .Where(item => !item.IsControlSignal && + FunctionalConstraintMatches(point.FunctionalConstraint, item.FunctionalConstraint) && expectedReferences.Contains(NormalizeReference(item.ObjectReference))) .ToList(); if (exactSignals.Count == 1) @@ -134,26 +136,28 @@ private static PointBinding BindPoint(IoTestPointPlan point, Iec61850MonitorDevi .ToHashSet(StringComparer.OrdinalIgnoreCase); var livePointCandidates = device.Points - .Where(item => MatchesAnyTelegram(item.IecReference, device, expectedTelegrams)) + .Where(item => FunctionalConstraintMatches(point.FunctionalConstraint, item.FunctionalConstraint) && + MatchesAnyTelegram(item.IecReference, device, expectedTelegrams)) .ToList(); if (livePointCandidates.Count == 1) { return new PointBinding( IoTestLiveBindingState.LivePointReady, - "Live point matched uniquely after normalizing the IED/Application wrapper.", + "Live point matched uniquely after normalizing IEC 61850 logical-device/display wrappers.", livePointCandidates[0].IecReference, livePointCandidates[0]); } var signalCandidates = device.Signals .Where(item => !item.IsControlSignal && + FunctionalConstraintMatches(point.FunctionalConstraint, item.FunctionalConstraint) && MatchesAnyTelegram(item.ObjectReference, device, expectedTelegrams)) .ToList(); if (signalCandidates.Count == 1) { return new PointBinding( IoTestLiveBindingState.BoundNormalized, - "Discovered signal matched uniquely after normalizing the IED/Application wrapper.", + "Discovered signal matched uniquely after normalizing IEC 61850 logical-device/display wrappers.", signalCandidates[0].ObjectReference, null); } @@ -163,7 +167,7 @@ private static PointBinding BindPoint(IoTestPointPlan point, Iec61850MonitorDevi ? "More than one live candidate matched the imported telegram; automatic binding was withheld." : device.Signals.Count == 0 ? "The IED is loaded but its signal model has not been discovered yet." - : "None of the imported IEC 61850/event-log references was found in the loaded IED model."; + : "None of the imported IEC 61850/event-log references was found in the loaded IED model with the required functional constraint."; return new PointBinding(IoTestLiveBindingState.SignalNotFound, reason, string.Empty, null); } @@ -220,6 +224,15 @@ private static string RemoveFunctionalConstraintSuffix(string? reference) return value; } + private static bool FunctionalConstraintMatches(string? expected, string? observed) + { + if (string.IsNullOrWhiteSpace(expected)) + return true; + + return !string.IsNullOrWhiteSpace(observed) && + expected.Trim().Equals(observed.Trim(), StringComparison.OrdinalIgnoreCase); + } + private static bool MatchesAnyTelegram( string? observedReference, Iec61850MonitorDevice device, @@ -269,25 +282,22 @@ internal static string NormalizeReference(string? reference) internal static string NormalizeTelegram(string? reference, string? iedName) { var normalized = NormalizeReference(RemoveFunctionalConstraintSuffix(reference)); - var slash = normalized.IndexOf('/'); - var name = (iedName ?? string.Empty).Trim().ToLowerInvariant(); - if (slash <= 0 || string.IsNullOrWhiteSpace(name)) - return normalized; - - var domain = normalized[..slash]; - if (!domain.StartsWith(name, StringComparison.OrdinalIgnoreCase)) - return normalized; - - var domainSuffix = domain[name.Length..]; - var path = normalized[(slash + 1)..].TrimStart('/'); - - // Rev.3 report traceability may use IEDNameApplication/LD/LN.DO.DA, - // while the discovered MMS model uses IEDNameLD/LN.DO.DA. Both identify - // the same telegram; "Application" is a display wrapper, not an LD name. - if (domainSuffix.Equals("application", StringComparison.OrdinalIgnoreCase)) - return path; - - return domainSuffix.Length == 0 ? path : domainSuffix + "/" + path; + if (normalized.Length == 0) + return string.Empty; + + // Exact-reference matching runs before this fallback and therefore preserves the + // logical-device identity whenever both sides expose it consistently. For FAT + // imports, however, vendor/report paths can contain one or more display/domain + // wrappers (for example IEDNameApplication/ADD/GGIO1...) while native MMS + // discovery can surface the same leaf as IEDNameApplication/GGIO1... or + // ADD/GGIO1.... Compare the LN.DO.DA tail only in this secondary path. + // Both callers require exactly one candidate; if two LDs expose the same LN/DO/DA + // tail, automatic binding remains ambiguous and is deliberately blocked. + var lastSlash = normalized.LastIndexOf('/'); + if (lastSlash >= 0 && lastSlash < normalized.Length - 1) + return normalized[(lastSlash + 1)..].TrimStart('/'); + + return normalized; } private sealed record PointBinding( diff --git a/Services/IoTesting/IoTestSignalSelectionService.cs b/Services/IoTesting/IoTestSignalSelectionService.cs index 774d225c..fc7843b3 100644 --- a/Services/IoTesting/IoTestSignalSelectionService.cs +++ b/Services/IoTesting/IoTestSignalSelectionService.cs @@ -21,8 +21,8 @@ public sealed record IoTestSignalSelectionResult( /// /// Resolves the enabled IO-list scope against one discovered IED model without /// guessing. Exact source/event-log references are preferred. A normalized -/// IED-name or Application wrapper is accepted only when it produces one unique -/// non-control signal. +/// logical-device/display wrapper is accepted only when it produces one unique +/// non-control signal with the required functional constraint. /// public sealed class IoTestSignalSelectionService { @@ -76,6 +76,9 @@ public IoTestSignalSelectionResult Resolve( continue; } + if (usedNormalizedPrefix) + ApplyImportedCanonicalReadReference(candidates[0], point); + matches.Add(new IoTestSignalMatch(point, candidates[0], usedNormalizedPrefix)); } @@ -105,8 +108,10 @@ private static bool IsEligible(SignalDefinition signal, IoTestPointPlan point) if (signal.IsControlSignal || string.IsNullOrWhiteSpace(signal.ObjectReference)) return false; - return string.IsNullOrWhiteSpace(point.FunctionalConstraint) || - string.IsNullOrWhiteSpace(signal.FunctionalConstraint) || + if (string.IsNullOrWhiteSpace(point.FunctionalConstraint)) + return true; + + return !string.IsNullOrWhiteSpace(signal.FunctionalConstraint) && signal.FunctionalConstraint.Equals(point.FunctionalConstraint, StringComparison.OrdinalIgnoreCase); } @@ -134,6 +139,77 @@ private static bool NormalizedTelegramMatches( return expected.Contains(observed); } + private static void ApplyImportedCanonicalReadReference( + SignalDefinition signal, + IoTestPointPlan point) + { + var canonical = BuildDirectImportedReadReference(point); + if (canonical.Length == 0) + return; + + var canonicalTail = IoTestLiveBindingService.NormalizeTelegram(canonical, point.IedName); + var discoveredTail = IoTestLiveBindingService.NormalizeTelegram(signal.ObjectReference, point.IedName); + if (canonicalTail.Length == 0 || + !canonicalTail.Equals(discoveredTail, StringComparison.OrdinalIgnoreCase)) + return; + + var oldReference = signal.ObjectReference; + if (IoTestLiveBindingService.NormalizeReference(oldReference) + .Equals(IoTestLiveBindingService.NormalizeReference(canonical), StringComparison.OrdinalIgnoreCase)) + return; + + signal.ObjectReference = canonical; + signal.DisplayReference = canonical; + signal.QualityReference = RebaseCompanionDomain(signal.QualityReference, canonical); + signal.TimestampReference = RebaseCompanionDomain(signal.TimestampReference, canonical); + signal.Source = string.IsNullOrWhiteSpace(signal.Source) + ? "IO FAT imported canonical MMS reference" + : $"{signal.Source} / IO FAT imported canonical MMS reference"; + } + + private static string BuildDirectImportedReadReference(IoTestPointPlan point) + { + foreach (var raw in new[] { point.EventLogSearchReference, point.SourceIecReference }) + { + var reference = RemoveFunctionalConstraintSuffix(raw); + if (reference.Length == 0 || reference.Count(ch => ch == '/') != 1) + continue; + + if (!string.IsNullOrWhiteSpace(point.DataAttribute) && + !reference.EndsWith("." + point.DataAttribute.Trim(), StringComparison.OrdinalIgnoreCase)) + { + reference += "." + point.DataAttribute.Trim(); + } + + return reference.Replace('$', '.'); + } + + return string.Empty; + } + + private static string RebaseCompanionDomain(string? companionReference, string canonicalReference) + { + var companion = (companionReference ?? string.Empty).Trim().Replace('$', '.'); + if (companion.Length == 0) + return string.Empty; + + var canonicalSlash = canonicalReference.IndexOf('/'); + var companionSlash = companion.LastIndexOf('/'); + if (canonicalSlash <= 0 || companionSlash < 0 || companionSlash >= companion.Length - 1) + return companion; + + return canonicalReference[..canonicalSlash] + companion[companionSlash..]; + } + + private static string RemoveFunctionalConstraintSuffix(string? reference) + { + var value = (reference ?? string.Empty).Trim(); + var marker = value.LastIndexOf(" [", StringComparison.Ordinal); + if (marker > 0 && value.EndsWith(']')) + value = value[..marker].TrimEnd(); + return value; + } + private static string Describe(IReadOnlyCollection points) { var values = points diff --git a/tests/ARSAS.Tests/IoFatApplicationWrapperBindingRegressionTests.cs b/tests/ARSAS.Tests/IoFatApplicationWrapperBindingRegressionTests.cs new file mode 100644 index 00000000..a9f35776 --- /dev/null +++ b/tests/ARSAS.Tests/IoFatApplicationWrapperBindingRegressionTests.cs @@ -0,0 +1,172 @@ +using ArIED61850Tester.Models; +using ArIED61850Tester.Models.IoTesting; +using ArIED61850Tester.Services.IoTesting; + +namespace ARSAS.Tests; + +public sealed class IoFatApplicationWrapperBindingRegressionTests +{ + private readonly IoTestLiveBindingService _binding = new(); + private readonly IoTestSignalSelectionService _selection = new(); + + [Fact] + public void TangguhApplicationAddWrapper_MatchesDiscoveryThatOmitsAdd_WhenUnique() + { + var project = Project("AA1C1F13R4Application/ADD/GGIO1.LocOpnCMDsta.stVal"); + var device = Device(); + device.Signals.Add(Signal("AA1C1F13R4Application/GGIO1.LocOpnCMDsta.stVal", "ST")); + + var summary = _binding.Bind(project, new[] { device }); + + Assert.Equal(1, summary.SignalBoundCount); + Assert.Equal(0, summary.MissingSignalCount); + Assert.Equal(IoTestLiveBindingState.BoundNormalized, project.Ieds[0].TestPoints[0].LiveBindingState); + } + + [Fact] + public void TangguhSelection_CanonicalizesUniqueWrapperMatchToDirectAddMmsReference() + { + var project = Project("AA1C1F13R4Application/ADD/GGIO1.LocOpnCMDsta.stVal"); + var device = Device(); + var signal = Signal("AA1C1F13R4Application/GGIO1.LocOpnCMDsta.stVal", "ST"); + signal.QualityReference = "AA1C1F13R4Application/GGIO1.LocOpnCMDsta.q"; + signal.TimestampReference = "AA1C1F13R4Application/GGIO1.LocOpnCMDsta.t"; + device.Signals.Add(signal); + + var result = _selection.Resolve(project.Ieds[0], device); + + Assert.True(result.Succeeded); + Assert.Single(result.Matches); + Assert.True(result.Matches[0].UsedNormalizedIedPrefix); + Assert.Equal("ADD/GGIO1.LocOpnCMDsta.stVal", signal.ObjectReference); + Assert.Equal("ADD/GGIO1.LocOpnCMDsta.q", signal.QualityReference); + Assert.Equal("ADD/GGIO1.LocOpnCMDsta.t", signal.TimestampReference); + Assert.Contains("IO FAT imported canonical MMS reference", signal.Source, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void TangguhApplicationAddWrapper_MatchesCanonicalAddDomain_WhenUnique() + { + var project = Project("AA1C1F13R4Application/ADD/GGIO1.LocOpnCMDsta.stVal"); + var device = Device(); + device.Signals.Add(Signal("ADD/GGIO1.LocOpnCMDsta.stVal", "ST")); + + var summary = _binding.Bind(project, new[] { device }); + + Assert.Equal(1, summary.SignalBoundCount); + Assert.Equal(IoTestLiveBindingState.BoundNormalized, project.Ieds[0].TestPoints[0].LiveBindingState); + } + + [Fact] + public void SameLnDoDaTailInTwoLogicalDevices_RemainsBlockedAsAmbiguous() + { + var project = Project("AA1C1F13R4Application/ADD/GGIO1.LocOpnCMDsta.stVal"); + var device = Device(); + device.Signals.Add(Signal("ADD/GGIO1.LocOpnCMDsta.stVal", "ST")); + device.Signals.Add(Signal("CTRL/GGIO1.LocOpnCMDsta.stVal", "ST")); + + var summary = _binding.Bind(project, new[] { device }); + var point = project.Ieds[0].TestPoints[0]; + + Assert.Equal(0, summary.SignalBoundCount); + Assert.Equal(1, summary.MissingSignalCount); + Assert.Equal(IoTestLiveBindingState.SignalNotFound, point.LiveBindingState); + Assert.Contains("more than one", point.LiveBindingReason, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void FunctionalConstraintMismatch_IsNeverAcceptedByWrapperFallback() + { + var project = Project("AA1C1F13R4Application/ADD/GGIO1.LocOpnCMDsta.stVal"); + var device = Device(); + device.Signals.Add(Signal("AA1C1F13R4Application/GGIO1.LocOpnCMDsta.stVal", "MX")); + + var summary = _binding.Bind(project, new[] { device }); + var selection = _selection.Resolve(project.Ieds[0], device); + + Assert.Equal(0, summary.SignalBoundCount); + Assert.Equal(1, summary.MissingSignalCount); + Assert.Equal(IoTestLiveBindingState.SignalNotFound, project.Ieds[0].TestPoints[0].LiveBindingState); + Assert.False(selection.Succeeded); + Assert.Single(selection.MissingPoints); + } + + [Fact] + public void UnknownDiscoveredFunctionalConstraint_IsNotAcceptedForKnownFatConstraint() + { + var project = Project("AA1C1F13R4Application/ADD/GGIO1.LocOpnCMDsta.stVal"); + var device = Device(); + device.Signals.Add(Signal("AA1C1F13R4Application/GGIO1.LocOpnCMDsta.stVal", string.Empty)); + + var summary = _binding.Bind(project, new[] { device }); + var result = _selection.Resolve(project.Ieds[0], device); + + Assert.Equal(0, summary.SignalBoundCount); + Assert.Equal(1, summary.MissingSignalCount); + Assert.Equal(IoTestLiveBindingState.SignalNotFound, project.Ieds[0].TestPoints[0].LiveBindingState); + Assert.False(result.Succeeded); + Assert.Single(result.MissingPoints); + } + + private static IoTestProject Project(string reference) + { + var project = new IoTestProject + { + ProjectId = "TANGGUH-UCC", + SchemaVersion = "ARSAS-FAT-IO-1.0", + ProjectName = "Tangguh UCC Project - Onshore EPCI", + Ieds = + { + new IoTestIedPlan + { + IedName = "AA1C1F13R4", + IpAddress = "192.168.81.17", + IedRole = "BCU", + TestPoints = + { + new IoTestPointPlan + { + TestPointId = "UCC-IEC-0698", + IedName = "AA1C1F13R4", + IpAddress = "192.168.81.17", + SignalName = "Selector local (LCC/CRP) control: CB Open command", + ObjectReference = reference, + SourceIecReference = "ADD/GGIO1.LocOpnCMDsta", + EventLogSearchReference = "ADD/GGIO1.LocOpnCMDsta", + ReportDisplayReference = reference + " [ST]", + LogicalDevice = "AA1C1F13R4Application", + LogicalNode = "GGIO1", + DataAttribute = "stVal", + FunctionalConstraint = "ST", + ExpectedOnText = "Active", + ExpectedOffText = "InActive", + ImportReady = true, + BindingStatus = "CID_DATASET_EXACT" + } + } + } + } + }; + project.InitializeRuntimeNotifications(); + return project; + } + + private static SignalDefinition Signal(string reference, string functionalConstraint) => new() + { + Name = "LocOpnCMDsta", + ObjectReference = reference, + FunctionalConstraint = functionalConstraint, + Category = "Status", + DataType = "Boolean", + Source = "Synthetic live discovery" + }; + + private static Iec61850MonitorDevice Device() => new() + { + Name = "AA1C1F13R4", + SclIedName = "AA1C1F13R4", + IpAddress = "192.168.81.17", + Port = 102, + Status = "Ready" + }; +}