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
90 changes: 90 additions & 0 deletions src/AR.Iec61850/Discovery/LiveIedDataSetTypeProbePlanner.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
using AR.Iec61850.Mms;

namespace AR.Iec61850.Discovery;

/// <summary>
/// Builds bounded typed-LN hints from live MMS DataSet directories. A successful
/// DataSet directory is independent evidence when GetNameList stopped early.
/// No SCL, guessed LN names, values, or extra MMS name scans are used.
/// </summary>
public static class LiveIedDataSetTypeProbePlanner
{
public static IReadOnlyList<MmsFcResolvedPoint> BuildVerifiedMemberHints(
MmsDiscoveryResult discovery,
int maxExtraLogicalNodes = 48,
int maxMemberHints = 2048)
{
ArgumentNullException.ThrowIfNull(discovery);
var extraLimit = Math.Clamp(maxExtraLogicalNodes, 0, 256);
var pointLimit = Math.Clamp(maxMemberHints, 0, 8192);
if (pointLimit == 0)
return Array.Empty<MmsFcResolvedPoint>();

// Only domains returned by this association can authorize probes.
var domains = discovery.Snapshot.DomainVariables.Keys
.Where(domain => !string.IsNullOrWhiteSpace(domain))
.ToHashSet(StringComparer.OrdinalIgnoreCase);
if (domains.Count == 0)
return Array.Empty<MmsFcResolvedPoint>();

static string RootKey(string domain, string ln) => domain + "\u001f" + ln;
var knownRoots = discovery.IedDirectory.LogicalDevices.Values
.SelectMany(ld => ld.LogicalNodes.Keys.Select(ln => RootKey(ld.Name, ln)))
.ToHashSet(StringComparer.OrdinalIgnoreCase);
var newRoots = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var addedReferences = new HashSet<string>(StringComparer.Ordinal);
var results = new List<MmsFcResolvedPoint>();

// ST first (event / control feedback), then MX; stable DataSet and
// membership order within each priority tier.
var liveMembers = discovery.DataSetDirectories
.Where(directory => directory.IsSuccess)
.OrderBy(directory => directory.DataSetReference, StringComparer.OrdinalIgnoreCase)
.SelectMany(directory => directory.Members)
.OrderBy(member => string.Equals(member.FunctionalConstraint, "ST",
StringComparison.OrdinalIgnoreCase) ? 0 : 1);

foreach (var member in liveMembers)
{
if (results.Count >= pointLimit)
break;
if (!domains.Contains(member.Domain) ||
string.IsNullOrWhiteSpace(member.MmsItemName) ||
!(string.Equals(member.FunctionalConstraint, "ST", StringComparison.OrdinalIgnoreCase) ||
string.Equals(member.FunctionalConstraint, "MX", StringComparison.OrdinalIgnoreCase)))
continue;

// Do not accept a fabricated LN, incorrect FC, or ambiguous DO.
if (!MmsIedModelDirectoryBuilder.TryParseLiveMmsVariable(
member.Domain, member.MmsItemName, out var point) ||
!string.Equals(point.LogicalNode, member.LogicalNode, StringComparison.OrdinalIgnoreCase) ||
!string.Equals(point.FunctionalConstraint, member.FunctionalConstraint,
StringComparison.OrdinalIgnoreCase) ||
!string.Equals(point.DataObjectPath, member.DataObjectPath,
StringComparison.OrdinalIgnoreCase))
continue;

var root = RootKey(point.Domain, point.LogicalNode);
if (!knownRoots.Contains(root) && !newRoots.Contains(root))
{
if (newRoots.Count >= extraLimit)
continue;
newRoots.Add(root);
}

if (addedReferences.Add(point.MmsReference))
results.Add(new MmsFcResolvedPoint
{
Domain = point.Domain,
LogicalNode = point.LogicalNode,
FunctionalConstraint = point.FunctionalConstraint,
DataObjectPath = point.DataObjectPath,
MmsItemName = point.MmsItemName,
Source = "LiveMmsDataSetDirectoryTypeHint",
Confidence = member.Confidence
});
}

return results;
}
}
24 changes: 24 additions & 0 deletions src/AR.Iec61850/Discovery/LiveIedVariableTypeHierarchy.cs
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,30 @@ public static IReadOnlyList<MmsObjectReference> BuildLogicalNodeRootCandidates(M
/// </summary>
public static class LiveIedVariableTypeProbeExecutor
{
/// <summary>
/// Proven DataSet members expand the association-scoped type-probe directory
/// before the existing one-pass LN -> DO -> leaf hierarchy ladder. This
/// resolves static report members omitted by capped GetNameList enumeration
/// without rescanning name pages or repeating prior LN probes.
/// </summary>
public static Task<IReadOnlyList<MmsVariableAccessAttributesResult>> ProbeSmartAsync(
MmsClientSession session,
MmsDiscoveryResult discovery,
MmsSmartDiscoveryOptions? options = null,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(session);
ArgumentNullException.ThrowIfNull(discovery);
options ??= new MmsSmartDiscoveryOptions();

var verified = LiveIedDataSetTypeProbePlanner.BuildVerifiedMemberHints(
discovery,
options.MaxDataSetTypeExtraLogicalNodes,
options.MaxDataSetTypeMemberHints);
discovery.IedDirectory.AddSupplementalPoints(verified);
return ProbeSmartAsync(session, discovery.IedDirectory, options, cancellationToken);
}

public static Task<IReadOnlyList<MmsVariableAccessAttributesResult>> ProbeSmartAsync(
MmsClientSession session,
MmsIedModelDirectory directory,
Expand Down
13 changes: 13 additions & 0 deletions src/AR.Iec61850/Mms/MmsClientSession.SmartDiscovery.cs
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,19 @@ public sealed class MmsSmartDiscoveryOptions

public bool ReadDataSetDirectories { get; init; }
public int MaxDataSetDirectoryReads { get; init; } = 64;

/// <summary>
/// Maximum additional, verified DataSet-member LN roots eligible for the
/// same type-probe pass when capped GetNameList omitted those roots.
/// This does not change the name-list budget or issue value reads.
/// </summary>
public int MaxDataSetTypeExtraLogicalNodes { get; init; } = 48;

/// <summary>
/// Bound on positional DataSet membership hints contributing type coverage.
/// Repeated exact MMS items are indexed once.
/// </summary>
public int MaxDataSetTypeMemberHints { get; init; } = 2048;
}

public sealed partial class MmsClientSession
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
using AR.Iec61850.Discovery;
using AR.Iec61850.Mms;

namespace AR.Iec61850.Tests.Discovery;

public sealed class LiveIedDataSetTypeProbePlannerTests
{
[Fact]
public void TruncatedNameList_StillAdmitsVerifiedStaticFeedbackRoots()
{
var discovery = Sample();
Assert.Equal(1, discovery.IedDirectory.LogicalNodeCount);
var hints = LiveIedDataSetTypeProbePlanner.BuildVerifiedMemberHints(discovery);

Assert.Contains(hints, x => x.LogicalNode == "eveGGIO1" && x.MmsItemName == "eveGGIO1$ST$Ind11");
Assert.Contains(hints, x => x.LogicalNode == "CSWI16" && x.MmsItemName == "CSWI16$ST$Pos");
Assert.Contains(hints, x => x.LogicalNode == "MMXU1" && x.MmsItemName == "MMXU1$MX$A");
Assert.DoesNotContain(hints, x => x.LogicalNode is "UNTRUSTED" or "BADFC" or "BADLN");

// The MMXU membership was already enumerated and must not be duplicated.
Assert.Equal(2, discovery.IedDirectory.AddSupplementalPoints(hints));
Assert.Equal(0, discovery.IedDirectory.AddSupplementalPoints(hints));
var roots = LiveIedVariableTypeProbePlanner.BuildLogicalNodeRootCandidates(discovery.IedDirectory);
Assert.Equal(3, roots.Count);
Assert.Contains(roots, root => root.Item == "CSWI16");
Assert.Contains(roots, root => root.Item == "eveGGIO1");
}

[Fact]
public void Budget_BoundsMissingRoots_AndDeduplicatesExactMemberships()
{
var discovery = Sample();
var hints = LiveIedDataSetTypeProbePlanner.BuildVerifiedMemberHints(
discovery, maxExtraLogicalNodes: 1, maxMemberHints: 16);

Assert.Contains(hints, x => x.LogicalNode == "MMXU1");
Assert.Contains(hints, x => x.LogicalNode == "CSWI16");
Assert.DoesNotContain(hints, x => x.LogicalNode == "eveGGIO1");
Assert.Equal(hints.Count, hints.Select(x => x.MmsReference)
.Distinct(StringComparer.Ordinal).Count());
Assert.Empty(LiveIedDataSetTypeProbePlanner.BuildVerifiedMemberHints(
discovery, maxExtraLogicalNodes: 0, maxMemberHints: 0));
}

private static MmsDiscoveryResult Sample()
{
var snapshot = new MmsDiscoverySnapshot
{
DomainVariables = new Dictionary<string, IReadOnlyList<string>>
{
["IEDLD0"] = ["MMXU1$MX$A"]
}
};
return new MmsDiscoveryResult
{
Snapshot = snapshot,
IedDirectory = MmsIedModelDirectoryBuilder.Build(snapshot),
DataSetDirectories =
[
new MmsDataSetDirectoryResult
{
IsSuccess = true,
DataSetReference = "IEDLD0/LLN0.Analog",
Members =
[
Member("MMXU1$MX$A", "MMXU1", "A", "MX"),
Member("CSWI16$ST$Pos", "CSWI16", "Pos", "ST"),
Member("CSWI16$ST$Pos", "CSWI16", "Pos", "ST"),
Member("eveGGIO1$ST$Ind11", "eveGGIO1", "Ind11", "ST"),
Member("UNTRUSTED$ST$Pos", "UNTRUSTED", "Pos", "ST", "OTHER"),
Member("BADFC$MX$A", "BADFC", "A", "ST")
]
},
new MmsDataSetDirectoryResult
{
IsSuccess = false,
DataSetReference = "IEDLD0/LLN0.Bad",
Members = [Member("BADLN$ST$Pos", "BADLN", "Pos", "ST")]
}
]
};
}

private static MmsDataSetDirectoryMember Member(string name, string ln, string path,
string fc, string domain = "IEDLD0")
=> new()
{
Domain = domain, MmsItemName = name, LogicalNode = ln,
DataObjectPath = path, FunctionalConstraint = fc, Confidence = 100
};
}