Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
133 changes: 125 additions & 8 deletions Models/IoTesting/IoSignalDisplayName.cs
Original file line number Diff line number Diff line change
Expand Up @@ -16,15 +16,132 @@ public static string Format(string? signalName, string? iecReference)
{
var name = string.IsNullOrWhiteSpace(signalName) ? "Signal" : signalName.Trim();
var reference = iecReference?.Trim() ?? string.Empty;
var match = PhaseToken().Match(reference);
if (!match.Success)
return name;

var phase = match.Groups["phase"].Value.ToUpperInvariant();
var suffix = $"Phs{phase}";
if (name.EndsWith($" {suffix}", StringComparison.OrdinalIgnoreCase))
return name;
var phaseMatch = PhaseToken().Match(reference);
if (phaseMatch.Success)
{
var phase = phaseMatch.Groups["phase"].Value.ToUpperInvariant();
var suffix = $"Phs{phase}";
if (name.EndsWith($" {suffix}", StringComparison.OrdinalIgnoreCase))
return name;

return $"{name} {suffix}";
return $"{name} {suffix}";
}

if (TryExtractLogicalNodeContext(reference, out var logicalNodeClass, out var dataObject)
&& RequiresLogicalNodeOwner(dataObject)
&& name.Equals(dataObject, StringComparison.OrdinalIgnoreCase))
{
return $"{logicalNodeClass} {name}";
}

return name;
}

private static bool TryExtractLogicalNodeContext(
string reference,
out string logicalNodeClass,
out string dataObject)
{
logicalNodeClass = string.Empty;
dataObject = string.Empty;

if (string.IsNullOrWhiteSpace(reference))
return false;

var slashIndex = reference.LastIndexOf('/');
var tail = slashIndex >= 0 && slashIndex + 1 < reference.Length
? reference[(slashIndex + 1)..]
: reference;

var dotIndex = tail.IndexOf('.');
var dollarIndex = tail.IndexOf('$');
var separatorIndex = dotIndex switch
{
>= 0 when dollarIndex >= 0 => Math.Min(dotIndex, dollarIndex),
>= 0 => dotIndex,
_ => dollarIndex
};

if (separatorIndex <= 0 || separatorIndex + 1 >= tail.Length)
return false;

var logicalNodeToken = tail[..separatorIndex].Trim();
if (!TryExtractLogicalNodeClass(logicalNodeToken, out logicalNodeClass))
return false;

var separator = tail[separatorIndex];
var remainder = tail[(separatorIndex + 1)..];
if (separator == '$')
{
var tokens = remainder.Split('$', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
if (tokens.Length == 0)
return false;

var dataObjectIndex = IsFunctionalConstraint(tokens[0]) ? 1 : 0;
if (dataObjectIndex >= tokens.Length)
return false;

dataObject = NormalizeDataObjectToken(tokens[dataObjectIndex]);
}
else
{
var tokenEnd = remainder.IndexOfAny(new[] { '.', '$', '[', '(' });
var token = tokenEnd >= 0 ? remainder[..tokenEnd] : remainder;
dataObject = NormalizeDataObjectToken(token);
}

return !string.IsNullOrWhiteSpace(dataObject);
}

private static bool TryExtractLogicalNodeClass(string logicalNodeToken, out string logicalNodeClass)
{
logicalNodeClass = string.Empty;
if (string.IsNullOrWhiteSpace(logicalNodeToken))
return false;

var token = logicalNodeToken.Trim();
if (token.Equals("LLN0", StringComparison.OrdinalIgnoreCase))
Comment on lines +103 to +104

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Recognize prefixed LLN0 tokens

When an MMS reference flattens a scope prefix into the logical-node token, such as the repository's SystemLLN0$ST$... form in IoTestSmartReferenceMatcherTests, this exact equality check misses LLN0; the subsequent digit stripping removes the significant 0 and derives an owner such as MLLN. Consequently, a reference like SystemLLN0$ST$Mod$stVal is displayed as MLLN Mod rather than LLN0 Mod. Detect a terminal LLN0 before applying the generic instance-number logic.

Useful? React with 👍 / 👎.

{
logicalNodeClass = "LLN0";
return true;
}

var end = token.Length;
while (end > 0 && char.IsDigit(token[end - 1]))
end--;

if (end == 0)
return false;

var withoutInstance = token[..end];
if (withoutInstance.Length >= 4)
{
var candidate = withoutInstance[^4..];
if (candidate.All(char.IsLetter))
{
logicalNodeClass = candidate.ToUpperInvariant();
return true;
}
}

logicalNodeClass = withoutInstance.ToUpperInvariant();
return true;
}

private static string NormalizeDataObjectToken(string token)
{
var value = token.Trim();
var end = value.IndexOfAny(new[] { '.', '$', '[', '(' });
return (end >= 0 ? value[..end] : value).Trim();
}

private static bool RequiresLogicalNodeOwner(string dataObject)
=> dataObject.ToUpperInvariant() is "POS" or "MOD" or "BEH" or "HEALTH" or "LOC" or "OPCNT";

private static bool IsFunctionalConstraint(string token)
=> token.ToUpperInvariant() is
"ST" or "MX" or "CO" or "SP" or "SV" or "CF" or "DC" or "SG" or "SE" or
"SR" or "OR" or "BL" or "EX" or "RP" or "BR" or "LG" or "GO" or "GS" or
"MS" or "US";
}
4 changes: 3 additions & 1 deletion Services/IoTesting/FatDataSetSignalProjectionService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,8 @@ private static FatVerificationSignal CreateRow(
staticReference);
var dataType = FirstNonEmpty(descriptor.MmsType, descriptor.SclBType, "Unknown");
var signalKind = Classify(descriptor.FunctionalConstraint, dataType);
var rawSignalName = FirstNonEmpty(descriptor.DataObject, descriptor.DataAttributePath, staticReference);
var displayReference = FirstNonEmpty(runtimeReference, staticReference);

return new FatVerificationSignal
{
Expand All @@ -94,7 +96,7 @@ private static FatVerificationSignal CreateRow(
DataSetMemberIndex = membership.MemberIndex,
StaticMemberReference = staticReference,
RuntimeReference = runtimeReference,
SignalName = FirstNonEmpty(descriptor.DataObject, descriptor.DataAttributePath, staticReference),
SignalName = IoFatSignalDisplayNameFormatter.Format(rawSignalName, displayReference),
FunctionalConstraint = descriptor.FunctionalConstraint,
DataType = dataType,
SignalKind = signalKind,
Expand Down
68 changes: 68 additions & 0 deletions tests/ARSAS.Tests/IoSignalDisplayNameTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
using ArIED61850Tester.Models.IoTesting;

namespace ARSAS.Tests;

public sealed class IoSignalDisplayNameTests
{
[Theory]
[InlineData("IED1MEAS/MMXU1.A.phsA.cVal.mag.f", "A PhsA")]
[InlineData("IED1MEAS/MMXU1.A.phsB.cVal.mag.f", "A PhsB")]
[InlineData("IED1MEAS/MMXU1.A.phsC.cVal.mag.f", "A PhsC")]
public void Format_AppendsPhaseContextWithoutAddingMeasurementOwner(string reference, string expected)
{
Assert.Equal(expected, IoSignalDisplayName.Format("A", reference));
}

[Theory]
[InlineData("IED1CTRL/XCBR1.Pos.stVal", "XCBR Pos")]
[InlineData("IED1CTRL/CSWI1.Pos.stVal", "CSWI Pos")]
[InlineData("IED1CTRL/XCBR1$ST$Pos$stVal", "XCBR Pos")]
public void Format_QualifiesAmbiguousDataObjectWithLogicalNodeOwner(string reference, string expected)
{
Assert.Equal(expected, IoSignalDisplayName.Format("Pos", reference));
}

[Theory]
[InlineData("Mod", "IED1CTRL/XCBR1.Mod.stVal", "XCBR Mod")]
[InlineData("Beh", "IED1CTRL/CSWI1.Beh.stVal", "CSWI Beh")]
[InlineData("Health", "IED1CTRL/LPHD1.Health.stVal", "LPHD Health")]
[InlineData("Loc", "IED1CTRL/CSWI1.Loc.stVal", "CSWI Loc")]
[InlineData("OpCnt", "IED1CTRL/XCBR1.OpCnt.stVal", "XCBR OpCnt")]
public void Format_UsesSameOwnerRuleForOtherGenericIecDataObjects(
string signalName,
string reference,
string expected)
{
Assert.Equal(expected, IoSignalDisplayName.Format(signalName, reference));
}

[Fact]
public void Format_DoesNotDuplicateExistingOwnerContext()
{
Assert.Equal(
"XCBR Pos",
IoSignalDisplayName.Format("XCBR Pos", "IED1CTRL/XCBR1.Pos.stVal"));
}

[Fact]
public void Format_LeavesSpecificDataObjectNameConcise()
{
Assert.Equal(
"Dig01",
IoSignalDisplayName.Format("Dig01", "IED1ADD/GGIO1.Dig01.stVal"));
}

[Fact]
public void Format_LeavesCustomLabelUntouched()
{
Assert.Equal(
"Breaker position",
IoSignalDisplayName.Format("Breaker position", "IED1CTRL/XCBR1.Pos.stVal"));
}

[Fact]
public void Format_FallsBackGracefullyWithoutReference()
{
Assert.Equal("Pos", IoSignalDisplayName.Format("Pos", null));
}
}
Loading