diff --git a/src/AR.Iec61850/Discovery/LiveIedDataSetTypeProbePlanner.cs b/src/AR.Iec61850/Discovery/LiveIedDataSetTypeProbePlanner.cs new file mode 100644 index 00000000..086ead0a --- /dev/null +++ b/src/AR.Iec61850/Discovery/LiveIedDataSetTypeProbePlanner.cs @@ -0,0 +1,90 @@ +using AR.Iec61850.Mms; + +namespace AR.Iec61850.Discovery; + +/// +/// 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. +/// +public static class LiveIedDataSetTypeProbePlanner +{ + public static IReadOnlyList 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(); + + // 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(); + + 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(StringComparer.OrdinalIgnoreCase); + var addedReferences = new HashSet(StringComparer.Ordinal); + var results = new List(); + + // 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; + } +} diff --git a/src/AR.Iec61850/Discovery/LiveIedVariableTypeHierarchy.cs b/src/AR.Iec61850/Discovery/LiveIedVariableTypeHierarchy.cs index a5932071..aa993cc1 100644 --- a/src/AR.Iec61850/Discovery/LiveIedVariableTypeHierarchy.cs +++ b/src/AR.Iec61850/Discovery/LiveIedVariableTypeHierarchy.cs @@ -31,6 +31,30 @@ public static IReadOnlyList BuildLogicalNodeRootCandidates(M /// public static class LiveIedVariableTypeProbeExecutor { + /// + /// 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. + /// + public static Task> 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> ProbeSmartAsync( MmsClientSession session, MmsIedModelDirectory directory, diff --git a/src/AR.Iec61850/Mms/MmsClientSession.SmartDiscovery.cs b/src/AR.Iec61850/Mms/MmsClientSession.SmartDiscovery.cs index c6fbfb14..9e855ebf 100644 --- a/src/AR.Iec61850/Mms/MmsClientSession.SmartDiscovery.cs +++ b/src/AR.Iec61850/Mms/MmsClientSession.SmartDiscovery.cs @@ -47,6 +47,19 @@ public sealed class MmsSmartDiscoveryOptions public bool ReadDataSetDirectories { get; init; } public int MaxDataSetDirectoryReads { get; init; } = 64; + + /// + /// 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. + /// + public int MaxDataSetTypeExtraLogicalNodes { get; init; } = 48; + + /// + /// Bound on positional DataSet membership hints contributing type coverage. + /// Repeated exact MMS items are indexed once. + /// + public int MaxDataSetTypeMemberHints { get; init; } = 2048; } public sealed partial class MmsClientSession diff --git a/tests/AR.Iec61850.Tests/Discovery/LiveIedDataSetTypeProbePlannerTests.cs b/tests/AR.Iec61850.Tests/Discovery/LiveIedDataSetTypeProbePlannerTests.cs new file mode 100644 index 00000000..00be4666 --- /dev/null +++ b/tests/AR.Iec61850.Tests/Discovery/LiveIedDataSetTypeProbePlannerTests.cs @@ -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> + { + ["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 + }; +}