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