Skip to content
Binary file modified Assets/ied-protection-relay-fascia.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
58 changes: 34 additions & 24 deletions Services/IoTesting/IoTestLiveBindingService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
{
Expand All @@ -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)
Expand All @@ -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);
}
Expand All @@ -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);
}

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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('/');
Comment on lines +296 to +298

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Preserve logical-device identity during normalization

When an import references ADD/GGIO1.LocOpnCMDsta.stVal but discovery contains only CTRL/GGIO1.LocOpnCMDsta.stVal, this strips both references to the same leaf. Since both binding services accept a single normalized candidate, they will select the CTRL signal instead of reporting the missing ADD signal, allowing FAT evidence to be captured from the wrong logical device. Preserve or explicitly map the logical-device components rather than dropping everything before the final slash.

Useful? React with 👍 / 👎.


return normalized;
}

private sealed record PointBinding(
Expand Down
84 changes: 80 additions & 4 deletions Services/IoTesting/IoTestSignalSelectionService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,8 @@ public sealed record IoTestSignalSelectionResult(
/// <summary>
/// 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.
/// </summary>
public sealed class IoTestSignalSelectionService
{
Expand Down Expand Up @@ -76,6 +76,9 @@ public IoTestSignalSelectionResult Resolve(
continue;
}

if (usedNormalizedPrefix)
ApplyImportedCanonicalReadReference(candidates[0], point);

matches.Add(new IoTestSignalMatch(point, candidates[0], usedNormalizedPrefix));
}

Expand Down Expand Up @@ -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);
}

Expand Down Expand Up @@ -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<IoTestPointPlan> points)
{
var values = points
Expand Down
Loading
Loading