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"
+ };
+}