From 439e15920cd28cb64bdbfb2df57d1729d1e740da Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 16 Sep 2026 19:16:49 +0700 Subject: [PATCH 01/66] perf(transport): serialize concurrent TPKT writes --- src/AR.Iec61850/Osi/TpktClient.cs | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/src/AR.Iec61850/Osi/TpktClient.cs b/src/AR.Iec61850/Osi/TpktClient.cs index ead7fe69..a78e8a7d 100644 --- a/src/AR.Iec61850/Osi/TpktClient.cs +++ b/src/AR.Iec61850/Osi/TpktClient.cs @@ -5,6 +5,7 @@ namespace AR.Iec61850.Osi; public sealed class TpktClient : IAsyncDisposable { + private readonly SemaphoreSlim _sendGate = new(1, 1); private TcpClient? _tcpClient; private NetworkStream? _stream; @@ -43,9 +44,6 @@ public async Task ConnectAsync(string host, int port, TimeSpan timeout, Cancella public async Task SendTpktAsync(ReadOnlyMemory payload, CancellationToken cancellationToken) { - if (_stream == null) - throw new InvalidOperationException("TPKT stream is not connected."); - if (payload.Length > ushort.MaxValue - 4) throw new ArgumentOutOfRangeException(nameof(payload), "TPKT payload is too large."); @@ -56,7 +54,16 @@ public async Task SendTpktAsync(ReadOnlyMemory payload, CancellationToken frame[3] = (byte)(frame.Length & 0xFF); payload.CopyTo(frame.AsMemory(4)); - await _stream.WriteAsync(frame, cancellationToken).ConfigureAwait(false); + await _sendGate.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + var stream = _stream ?? throw new InvalidOperationException("TPKT stream is not connected."); + await stream.WriteAsync(frame, cancellationToken).ConfigureAwait(false); + } + finally + { + _sendGate.Release(); + } } public async Task ReceiveTpktAsync(CancellationToken cancellationToken) From 9f8b4d5aeb09d219d63dc36bc94f06482e6661ce Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 16 Sep 2026 19:17:14 +0700 Subject: [PATCH 02/66] perf(discovery): add bounded pipelined smart discovery --- .../Mms/MmsClientSession.SmartDiscovery.cs | 224 ++++++++++++++++++ 1 file changed, 224 insertions(+) create mode 100644 src/AR.Iec61850/Mms/MmsClientSession.SmartDiscovery.cs diff --git a/src/AR.Iec61850/Mms/MmsClientSession.SmartDiscovery.cs b/src/AR.Iec61850/Mms/MmsClientSession.SmartDiscovery.cs new file mode 100644 index 00000000..50e9d369 --- /dev/null +++ b/src/AR.Iec61850/Mms/MmsClientSession.SmartDiscovery.cs @@ -0,0 +1,224 @@ +namespace AR.Iec61850.Mms; + +/// +/// Capture-informed discovery policy for building a usable IEC 61850 model with +/// fewer round trips. Independent GetNameList chains may run concurrently, while +/// continuation pages inside one chain always remain sequential. +/// +public sealed class MmsSmartDiscoveryOptions +{ + /// + /// Requested upper bound for independent domain/object-class discovery chains. + /// The effective value is additionally capped by the peer's negotiated + /// maxOutstandingCalling value when that evidence is available. + /// + public int MaxConcurrentChains { get; init; } = 8; + + /// + /// Conservative cap used when the peer's maxOutstandingCalling value could not + /// be decoded from InitiateResponse. + /// + public int UnknownPeerMaxConcurrentChains { get; init; } = 4; + + public int MaxDomains { get; init; } = 256; + public int MaxVariableNamesPerDomain { get; init; } = 20000; + public int MaxVariableListNamesPerDomain { get; init; } = 4096; + public bool ProbeReportAttributes { get; init; } = true; + public int MaxReportAttributeProbes { get; init; } = 32; + public bool ReadDataSetDirectories { get; init; } + public int MaxDataSetDirectoryReads { get; init; } = 64; +} + +public sealed partial class MmsClientSession +{ + /// + /// Builds the same structural discovery result as the legacy discovery path, + /// but enumerates VMD domains once and pipelines independent per-domain + /// GetNameList chains. No eager per-leaf Read or GetVariableAccessAttributes + /// sweep is performed by this method. + /// + public async Task DiscoverSmartAsync( + MmsSmartDiscoveryOptions? options = null, + CancellationToken cancellationToken = default) + { + EnsureMmsReady(); + options ??= new MmsSmartDiscoveryOptions(); + + var maxDomains = Math.Clamp(options.MaxDomains, 1, 4096); + var maxVariables = Math.Clamp(options.MaxVariableNamesPerDomain, 1, 100000); + var maxVariableLists = Math.Clamp(options.MaxVariableListNamesPerDomain, 1, 20000); + var effectiveWindow = ResolveSmartDiscoveryWindow(options); + + var domainsResult = await GetNameListPagedAsync( + MmsGetNameListObjectClass.Domain, + null, + cancellationToken) + .ConfigureAwait(false); + + var domains = domainsResult.IsSuccess + ? domainsResult.Names + .Where(name => !string.IsNullOrWhiteSpace(name)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .OrderBy(name => name, StringComparer.OrdinalIgnoreCase) + .Take(maxDomains) + .ToArray() + : Array.Empty(); + + using var gate = new SemaphoreSlim(effectiveWindow, effectiveWindow); + var chainTasks = new List>(domains.Length * 2); + foreach (var domain in domains) + { + chainTasks.Add(ReadSmartNameChainAsync( + domain, + MmsGetNameListObjectClass.NamedVariable, + maxVariables, + gate, + cancellationToken)); + chainTasks.Add(ReadSmartNameChainAsync( + domain, + MmsGetNameListObjectClass.NamedVariableList, + maxVariableLists, + gate, + cancellationToken)); + } + + var chains = chainTasks.Count == 0 + ? Array.Empty() + : await Task.WhenAll(chainTasks).ConfigureAwait(false); + + // Final dictionaries are rebuilt in sorted domain order so completion timing + // never changes the published model ordering. + var variablesByKey = chains + .Where(chain => chain.ObjectClass == MmsGetNameListObjectClass.NamedVariable) + .ToDictionary(chain => chain.Domain, StringComparer.OrdinalIgnoreCase); + var listsByKey = chains + .Where(chain => chain.ObjectClass == MmsGetNameListObjectClass.NamedVariableList) + .ToDictionary(chain => chain.Domain, StringComparer.OrdinalIgnoreCase); + + var domainVariables = new Dictionary>(StringComparer.OrdinalIgnoreCase); + var domainVariableLists = new Dictionary>(StringComparer.OrdinalIgnoreCase); + foreach (var domain in domains) + { + domainVariables[domain] = variablesByKey.TryGetValue(domain, out var variables) + ? variables.Names + : Array.Empty(); + domainVariableLists[domain] = listsByKey.TryGetValue(domain, out var lists) + ? lists.Names + : Array.Empty(); + } + + var snapshot = new MmsDiscoverySnapshot + { + DomainVariables = domainVariables, + DomainVariableLists = domainVariableLists + }; + + var inventory = MmsReportDiscoveryMapper.BuildInventory(snapshot); + var iedDirectory = MmsIedModelDirectoryBuilder.Build(snapshot); + + if (options.ProbeReportAttributes) + { + await EnrichReportInventoryAsync( + inventory, + Math.Max(0, options.MaxReportAttributeProbes), + cancellationToken) + .ConfigureAwait(false); + } + + var dataSetReferences = options.ReadDataSetDirectories + ? inventory.DataSets + .Select(dataSet => dataSet.Reference) + .Where(reference => !string.IsNullOrWhiteSpace(reference)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .Take(Math.Clamp(options.MaxDataSetDirectoryReads, 0, 4096)) + .ToArray() + : Array.Empty(); + + var dataSetDirectories = dataSetReferences.Length == 0 + ? Array.Empty() + : (await GetDataSetDirectoriesAsync( + dataSetReferences, + iedDirectory, + cancellationToken) + .ConfigureAwait(false)) + .ToArray(); + + var failedChains = chains.Count(chain => !chain.IsSuccess); + var successfulDataSetDirectories = dataSetDirectories.Count(directory => directory.IsSuccess); + var discoveredDataSetMembers = dataSetDirectories + .Where(directory => directory.IsSuccess) + .Sum(directory => directory.Members.Count); + var negotiated = LastNegotiatedCapabilities.MaxOutstandingCalling; + var negotiatedText = negotiated.HasValue ? negotiated.Value.ToString() : "unknown"; + var domainStatus = domainsResult.IsSuccess + ? $"domains={domains.Length}" + : $"domain-list-failed={domainsResult.Message}"; + var dataSetDirectorySummary = options.ReadDataSetDirectories + ? $"dataset directories={successfulDataSetDirectories}/{dataSetDirectories.Length}, dataset members={discoveredDataSetMembers}" + : "dataset directories=not requested"; + + LastDiscoveryAttemptSummary = + $"Smart discovery: {domainStatus}, chains={chains.Length}, failedChains={failedChains}, " + + $"window={effectiveWindow}, negotiatedCalling={negotiatedText}."; + + return new MmsDiscoveryResult + { + Snapshot = snapshot, + ReportInventory = inventory, + IedDirectory = iedDirectory, + DataSetDirectories = dataSetDirectories, + Summary = + $"Smart MMS discovery: LD={snapshot.DomainCount}, raw variables={snapshot.RawVariableCount}, " + + $"FC-points={iedDirectory.PointCount}, datasets={inventory.DataSets.Count}, {dataSetDirectorySummary}, " + + $"RCB={inventory.ReportControls.Count} (BRCB={inventory.BufferedCount}, URCB={inventory.UnbufferedCount}). " + + LastDiscoveryAttemptSummary + }; + } + + internal int ResolveSmartDiscoveryWindow(MmsSmartDiscoveryOptions options) + { + ArgumentNullException.ThrowIfNull(options); + + var requested = Math.Clamp(options.MaxConcurrentChains, 1, 32); + var unknownPeerCap = Math.Clamp(options.UnknownPeerMaxConcurrentChains, 1, 16); + var negotiated = LastNegotiatedCapabilities.MaxOutstandingCalling; + + if (negotiated is > 0) + return Math.Max(1, Math.Min(requested, negotiated.Value)); + + return Math.Min(requested, unknownPeerCap); + } + + private async Task ReadSmartNameChainAsync( + string domain, + MmsGetNameListObjectClass objectClass, + int maxNames, + SemaphoreSlim gate, + CancellationToken cancellationToken) + { + await gate.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + var result = await GetNameListPagedAsync(objectClass, domain, cancellationToken).ConfigureAwait(false); + var names = result.Names + .Where(name => !string.IsNullOrWhiteSpace(name)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .OrderBy(name => name, StringComparer.OrdinalIgnoreCase) + .Take(maxNames) + .ToArray(); + + return new SmartNameChainResult(domain, objectClass, result.IsSuccess, names, result.Message); + } + finally + { + gate.Release(); + } + } + + private sealed record SmartNameChainResult( + string Domain, + MmsGetNameListObjectClass ObjectClass, + bool IsSuccess, + IReadOnlyList Names, + string Message); +} From aed18dc8a2629d21ab189db9133c754dbb3b6f31 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 16 Sep 2026 19:18:08 +0700 Subject: [PATCH 03/66] perf(discovery): add structure-first type enrichment --- ...ntSession.SmartVariableAccessAttributes.cs | 123 ++++++++++++++++++ 1 file changed, 123 insertions(+) create mode 100644 src/AR.Iec61850/Mms/MmsClientSession.SmartVariableAccessAttributes.cs diff --git a/src/AR.Iec61850/Mms/MmsClientSession.SmartVariableAccessAttributes.cs b/src/AR.Iec61850/Mms/MmsClientSession.SmartVariableAccessAttributes.cs new file mode 100644 index 00000000..7132f960 --- /dev/null +++ b/src/AR.Iec61850/Mms/MmsClientSession.SmartVariableAccessAttributes.cs @@ -0,0 +1,123 @@ +namespace AR.Iec61850.Mms; + +public sealed partial class MmsClientSession +{ + /// + /// Reads variable type metadata structure-first. One GetVariableAccessAttributes + /// request is issued for each IEC 61850 LN/FC/data-object root. Leaf probes are + /// used only when the root request fails or does not return a structured type. + /// This preserves exact type discovery while avoiding an eager request for every + /// discovered leaf on IEDs that expose the normal MMS structure hierarchy. + /// + public async Task> GetVariableAccessAttributesSmartAsync( + MmsIedModelDirectory directory, + MmsSmartDiscoveryOptions? options = null, + CancellationToken cancellationToken = default) + { + EnsureMmsReady(); + ArgumentNullException.ThrowIfNull(directory); + options ??= new MmsSmartDiscoveryOptions(); + + var groups = directory.Points + .Where(point => !string.IsNullOrWhiteSpace(point.Domain) && !string.IsNullOrWhiteSpace(point.MmsItemName)) + .GroupBy( + point => BuildSmartTypeRoot(point), + MmsObjectReferenceKeyComparer.Instance) + .OrderBy(group => group.Key.Domain, StringComparer.OrdinalIgnoreCase) + .ThenBy(group => group.Key.Item, StringComparer.OrdinalIgnoreCase) + .ToArray(); + + if (groups.Length == 0) + return Array.Empty(); + + var window = ResolveSmartDiscoveryWindow(options); + using var gate = new SemaphoreSlim(window, window); + + var rootTasks = groups + .Select(group => ReadVariableAttributesBoundedAsync(group.Key, gate, cancellationToken)) + .ToArray(); + var rootResults = await Task.WhenAll(rootTasks).ConfigureAwait(false); + + var results = new List(rootResults.Length); + results.AddRange(rootResults); + + var fallbackReferences = new List(); + for (var index = 0; index < groups.Length; index++) + { + var group = groups[index]; + var rootResult = rootResults[index]; + var hasDescendants = group.Any(point => + !point.MmsItemName.Equals(group.Key.Item, StringComparison.OrdinalIgnoreCase)); + var rootDescribesHierarchy = rootResult.IsSuccess && + (!hasDescendants || rootResult.TypeSpecification?.Children.Count > 0); + + if (rootDescribesHierarchy) + continue; + + foreach (var point in group) + { + var reference = point.ToObjectReference(); + if (reference.Item.Equals(group.Key.Item, StringComparison.OrdinalIgnoreCase)) + continue; + fallbackReferences.Add(reference); + } + } + + var distinctFallbacks = fallbackReferences + .Distinct(MmsObjectReferenceKeyComparer.Instance) + .OrderBy(reference => reference.Domain, StringComparer.OrdinalIgnoreCase) + .ThenBy(reference => reference.Item, StringComparer.OrdinalIgnoreCase) + .ToArray(); + + if (distinctFallbacks.Length > 0 && IsMmsInitiated) + { + var fallbackTasks = distinctFallbacks + .Select(reference => ReadVariableAttributesBoundedAsync(reference, gate, cancellationToken)) + .ToArray(); + results.AddRange(await Task.WhenAll(fallbackTasks).ConfigureAwait(false)); + } + + return results; + } + + private async Task ReadVariableAttributesBoundedAsync( + MmsObjectReference reference, + SemaphoreSlim gate, + CancellationToken cancellationToken) + { + await gate.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + return await GetVariableAccessAttributesAsync(reference, cancellationToken).ConfigureAwait(false); + } + finally + { + gate.Release(); + } + } + + private static MmsObjectReference BuildSmartTypeRoot(MmsFcResolvedPoint point) + { + var parts = point.MmsItemName.Split( + '$', + StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + var item = parts.Length >= 3 + ? string.Join('$', parts.Take(3)) + : point.MmsItemName; + return new MmsObjectReference(point.Domain, item, point.FunctionalConstraint); + } + + private sealed class MmsObjectReferenceKeyComparer : IEqualityComparer + { + public static MmsObjectReferenceKeyComparer Instance { get; } = new(); + + public bool Equals(MmsObjectReference x, MmsObjectReference y) + => x.Domain.Equals(y.Domain, StringComparison.OrdinalIgnoreCase) && + x.Item.Equals(y.Item, StringComparison.OrdinalIgnoreCase); + + public int GetHashCode(MmsObjectReference obj) + => HashCode.Combine( + StringComparer.OrdinalIgnoreCase.GetHashCode(obj.Domain ?? string.Empty), + StringComparer.OrdinalIgnoreCase.GetHashCode(obj.Item ?? string.Empty)); + } +} From 779c66447f3b52248e3d5cec9ac70cd74da8846f Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 16 Sep 2026 19:18:32 +0700 Subject: [PATCH 04/66] docs(discovery): document smart MMS pipeline --- docs/SMART_MMS_DISCOVERY.md | 65 +++++++++++++++++++++++++++++++++++++ 1 file changed, 65 insertions(+) create mode 100644 docs/SMART_MMS_DISCOVERY.md diff --git a/docs/SMART_MMS_DISCOVERY.md b/docs/SMART_MMS_DISCOVERY.md new file mode 100644 index 00000000..085d24e4 --- /dev/null +++ b/docs/SMART_MMS_DISCOVERY.md @@ -0,0 +1,65 @@ +# Smart MMS discovery + +`MmsClientSession.DiscoverSmartAsync` is the capture-informed discovery path for fast, deterministic IEC 61850 model construction. + +## Design invariants + +- One TCP/COTP/MMS association and one receive pump per session. +- Confirmed responses are correlated by invoke ID; discovery never starts a second raw receive loop. +- The VMD domain list is enumerated once per discovery operation. +- Independent `(domain, object-class)` GetNameList chains may be outstanding concurrently. +- Continuation pages inside one GetNameList chain are always sequential. +- The effective discovery window is capped by the peer's negotiated `maxOutstandingCalling` when available. Unknown peers use a conservative fallback cap. +- Published dictionaries are rebuilt in sorted domain order, so concurrent completion never changes model ordering. +- A failed domain chain produces an empty branch while successful branches remain available in the returned structural model. +- Initial smart discovery does not perform an eager per-leaf Read or GetVariableAccessAttributes sweep. + +## Type enrichment + +`GetVariableAccessAttributesSmartAsync` performs structure-first type discovery: + +1. Group discovered FC points by MMS `LN$FC$DO` root. +2. Probe each root with GetVariableAccessAttributes using the same bounded association-aware window. +3. When a root returns a structured TypeSpecification, its hierarchy represents descendant attributes. +4. When the root probe fails or does not describe a hierarchy, fall back to exact leaf probes for that root only. + +This keeps exact metadata available while avoiding one GetVariableAccessAttributes request per leaf on normal IEC 61850 MMS models. + +## Transport safety + +Pipelining requires multiple confirmed requests to be outstanding. `TpktClient` therefore serializes writers around each complete TPKT frame. This is intentionally a write-frame gate only: it prevents byte interleaving without serializing the request/response lifecycle. The existing single receive pump remains the only association reader. + +## Suggested usage + +```csharp +var discovery = await session.DiscoverSmartAsync( + new MmsSmartDiscoveryOptions + { + MaxConcurrentChains = 8, + ProbeReportAttributes = true, + ReadDataSetDirectories = true + }, + cancellationToken); + +var exactTypes = await session.GetVariableAccessAttributesSmartAsync( + discovery.IedDirectory, + cancellationToken: cancellationToken); +``` + +The legacy `DiscoverAsync` and `GetVariableAccessAttributesBatchAsync` APIs remain unchanged for compatibility. Consumers can migrate deliberately and compare model completeness before making smart discovery their default. + +## Capture-informed target + +The reference capture used during this refactor showed the existing consumer issuing roughly 30.7k confirmed MMS requests, including roughly 23.7k GetVariableAccessAttributes and 6.6k Read requests, while the comparison tool used a much smaller, pipelined request set. These values are benchmark evidence, not protocol requirements. The smart path targets the scheduling pattern—single association, bounded outstanding requests, structural discovery first—without copying vendor-specific behavior. + +For acceptance, compare the same IED and capture conditions using: + +- time to first visible model item; +- time to usable LD/LN/DO/DA tree; +- final LD/LN/FC-point and dataset counts; +- confirmed request count by service; +- peak outstanding confirmed requests; +- failed/partial domain chains; +- exact type coverage after smart enrichment. + +A performance result is accepted only when the final model remains semantically equivalent for the required scope. From 7217552ad73d795ed2feed5263d559090646c0e1 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 16 Sep 2026 19:20:11 +0700 Subject: [PATCH 05/66] fix(discovery): surface incomplete pagination as partial --- .../Mms/MmsClientSession.SmartDiscovery.cs | 27 ++++++++++++++----- 1 file changed, 20 insertions(+), 7 deletions(-) diff --git a/src/AR.Iec61850/Mms/MmsClientSession.SmartDiscovery.cs b/src/AR.Iec61850/Mms/MmsClientSession.SmartDiscovery.cs index 50e9d369..62bb179d 100644 --- a/src/AR.Iec61850/Mms/MmsClientSession.SmartDiscovery.cs +++ b/src/AR.Iec61850/Mms/MmsClientSession.SmartDiscovery.cs @@ -55,6 +55,10 @@ public async Task DiscoverSmartAsync( cancellationToken) .ConfigureAwait(false); + // GetNameListPagedAsync deliberately preserves collected names if an endpoint + // stops making pagination progress. MoreFollows therefore means partial, + // even when the legacy result has IsSuccess=true. + var domainListComplete = domainsResult.IsSuccess && !domainsResult.MoreFollows; var domains = domainsResult.IsSuccess ? domainsResult.Names .Where(name => !string.IsNullOrWhiteSpace(name)) @@ -143,22 +147,24 @@ await EnrichReportInventoryAsync( .ConfigureAwait(false)) .ToArray(); - var failedChains = chains.Count(chain => !chain.IsSuccess); + var failedChains = chains.Count(chain => !chain.IsComplete); var successfulDataSetDirectories = dataSetDirectories.Count(directory => directory.IsSuccess); var discoveredDataSetMembers = dataSetDirectories .Where(directory => directory.IsSuccess) .Sum(directory => directory.Members.Count); var negotiated = LastNegotiatedCapabilities.MaxOutstandingCalling; var negotiatedText = negotiated.HasValue ? negotiated.Value.ToString() : "unknown"; - var domainStatus = domainsResult.IsSuccess - ? $"domains={domains.Length}" - : $"domain-list-failed={domainsResult.Message}"; + var domainStatus = !domainsResult.IsSuccess + ? $"domain-list-failed={domainsResult.Message}" + : domainListComplete + ? $"domains={domains.Length}" + : $"domains-partial={domains.Length}, moreFollows=true"; var dataSetDirectorySummary = options.ReadDataSetDirectories ? $"dataset directories={successfulDataSetDirectories}/{dataSetDirectories.Length}, dataset members={discoveredDataSetMembers}" : "dataset directories=not requested"; LastDiscoveryAttemptSummary = - $"Smart discovery: {domainStatus}, chains={chains.Length}, failedChains={failedChains}, " + + $"Smart discovery: {domainStatus}, chains={chains.Length}, incompleteChains={failedChains}, " + $"window={effectiveWindow}, negotiatedCalling={negotiatedText}."; return new MmsDiscoveryResult @@ -207,7 +213,14 @@ private async Task ReadSmartNameChainAsync( .Take(maxNames) .ToArray(); - return new SmartNameChainResult(domain, objectClass, result.IsSuccess, names, result.Message); + return new SmartNameChainResult( + domain, + objectClass, + result.IsSuccess && !result.MoreFollows, + names, + result.MoreFollows + ? $"Partial GetNameList chain: IED still reported moreFollows. {result.Message}" + : result.Message); } finally { @@ -218,7 +231,7 @@ private async Task ReadSmartNameChainAsync( private sealed record SmartNameChainResult( string Domain, MmsGetNameListObjectClass ObjectClass, - bool IsSuccess, + bool IsComplete, IReadOnlyList Names, string Message); } From 1fbe9b1a72b00558b81620d927674d0a1556cb58 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 16 Sep 2026 19:30:26 +0700 Subject: [PATCH 06/66] refactor(discovery): centralize smart scheduling policy --- .../Mms/MmsSmartDiscoveryPolicy.cs | 131 ++++++++++++++++++ 1 file changed, 131 insertions(+) create mode 100644 src/AR.Iec61850/Mms/MmsSmartDiscoveryPolicy.cs diff --git a/src/AR.Iec61850/Mms/MmsSmartDiscoveryPolicy.cs b/src/AR.Iec61850/Mms/MmsSmartDiscoveryPolicy.cs new file mode 100644 index 00000000..1083da64 --- /dev/null +++ b/src/AR.Iec61850/Mms/MmsSmartDiscoveryPolicy.cs @@ -0,0 +1,131 @@ +namespace AR.Iec61850.Mms; + +/// +/// Pure scheduling and type-tree rules shared by smart discovery. Keeping these +/// decisions side-effect free makes them deterministic, cheap to test, and safe to +/// reuse from SCL-assisted and live-only orchestration. +/// +internal static class MmsSmartDiscoveryPolicy +{ + public static int ResolveWindow(int requested, int unknownPeerCap, int? negotiatedMaxOutstandingCalling) + { + var requestedBound = Math.Clamp(requested, 1, 32); + var unknownBound = Math.Clamp(unknownPeerCap, 1, 16); + + if (negotiatedMaxOutstandingCalling is > 0) + return Math.Max(1, Math.Min(requestedBound, negotiatedMaxOutstandingCalling.Value)); + + return Math.Min(requestedBound, unknownBound); + } + + /// + /// Selects the live domain set independently from any SCL hint. This is important: + /// SCL may prioritize online work, but it must never manufacture or suppress live + /// MMS evidence. + /// + public static string[] SelectPublishedDomains(IEnumerable observedDomains, int maxDomains) + { + ArgumentNullException.ThrowIfNull(observedDomains); + + return observedDomains + .Where(domain => !string.IsNullOrWhiteSpace(domain)) + .Select(domain => domain.Trim()) + .Distinct(StringComparer.Ordinal) + .OrderBy(domain => domain, StringComparer.Ordinal) + .Take(Math.Clamp(maxDomains, 1, 4096)) + .ToArray(); + } + + /// + /// Reorders an already-selected live domain set for scheduling only. Exact SCL + /// domain hints are attempted first, while unknown hints are ignored and every + /// selected live domain remains present exactly once. + /// + public static string[] OrderDomainsForScheduling( + IReadOnlyList selectedLiveDomains, + IReadOnlyList? priorityDomains) + { + ArgumentNullException.ThrowIfNull(selectedLiveDomains); + if (selectedLiveDomains.Count <= 1 || priorityDomains is null || priorityDomains.Count == 0) + return selectedLiveDomains.ToArray(); + + var live = new HashSet(selectedLiveDomains, StringComparer.Ordinal); + var emitted = new HashSet(StringComparer.Ordinal); + var ordered = new List(selectedLiveDomains.Count); + + foreach (var hint in priorityDomains) + { + if (string.IsNullOrWhiteSpace(hint)) + continue; + + var normalized = hint.Trim(); + if (live.Contains(normalized) && emitted.Add(normalized)) + ordered.Add(normalized); + } + + foreach (var domain in selectedLiveDomains) + { + if (emitted.Add(domain)) + ordered.Add(domain); + } + + return ordered.ToArray(); + } +} + +internal static class MmsSmartTypeProbePolicy +{ + public static MmsObjectReference BuildDataObjectRoot(MmsFcResolvedPoint point) + { + ArgumentNullException.ThrowIfNull(point); + + var parts = Split(point.MmsItemName); + var item = parts.Length >= 3 + ? string.Join('$', parts.Take(3)) + : point.MmsItemName; + return new MmsObjectReference(point.Domain, item, point.FunctionalConstraint); + } + + /// + /// Returns true only when the supplied GVA result can prove the requested MMS + /// item through its TypeSpecification hierarchy. A successful but shallow result + /// is deliberately not treated as coverage for descendants. + /// + public static bool Covers( + MmsVariableAccessAttributesResult result, + string targetMmsItemName) + { + if (!result.IsSuccess || result.TypeSpecification is null || string.IsNullOrWhiteSpace(targetMmsItemName)) + return false; + + var rootParts = Split(result.Reference.Item); + var targetParts = Split(targetMmsItemName); + if (rootParts.Length == 0 || targetParts.Length < rootParts.Length) + return false; + + for (var index = 0; index < rootParts.Length; index++) + { + if (!string.Equals(rootParts[index], targetParts[index], StringComparison.OrdinalIgnoreCase)) + return false; + } + + var current = result.TypeSpecification; + for (var index = rootParts.Length; index < targetParts.Length; index++) + { + var part = targetParts[index]; + var next = current.Children.FirstOrDefault(child => + string.Equals(child.Name, part, StringComparison.OrdinalIgnoreCase)); + if (next is null) + return false; + + current = next; + } + + return true; + } + + private static string[] Split(string value) + => (value ?? string.Empty).Split( + '$', + StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); +} From 82257bc5d337c4d18dc6b71c29cfcf80dbff9977 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 16 Sep 2026 19:31:35 +0700 Subject: [PATCH 07/66] perf(discovery): make smart paging bounded and fail-soft --- .../Mms/MmsClientSession.SmartDiscovery.cs | 414 ++++++++++++++---- 1 file changed, 333 insertions(+), 81 deletions(-) diff --git a/src/AR.Iec61850/Mms/MmsClientSession.SmartDiscovery.cs b/src/AR.Iec61850/Mms/MmsClientSession.SmartDiscovery.cs index 62bb179d..0d2426b4 100644 --- a/src/AR.Iec61850/Mms/MmsClientSession.SmartDiscovery.cs +++ b/src/AR.Iec61850/Mms/MmsClientSession.SmartDiscovery.cs @@ -1,30 +1,50 @@ +using AR.Iec61850.Diagnostics; + namespace AR.Iec61850.Mms; /// /// Capture-informed discovery policy for building a usable IEC 61850 model with -/// fewer round trips. Independent GetNameList chains may run concurrently, while -/// continuation pages inside one chain always remain sequential. +/// bounded work. Live MMS remains authoritative; optional domain hints only affect +/// scheduling order and never filter or manufacture online evidence. /// public sealed class MmsSmartDiscoveryOptions { /// - /// Requested upper bound for independent domain/object-class discovery chains. - /// The effective value is additionally capped by the peer's negotiated - /// maxOutstandingCalling value when that evidence is available. + /// Requested upper bound for independent discovery operations. The effective + /// value is additionally capped by the peer's negotiated maxOutstandingCalling + /// when that evidence is available. /// public int MaxConcurrentChains { get; init; } = 8; /// - /// Conservative cap used when the peer's maxOutstandingCalling value could not - /// be decoded from InitiateResponse. + /// Conservative cap used when maxOutstandingCalling could not be decoded. /// public int UnknownPeerMaxConcurrentChains { get; init; } = 4; public int MaxDomains { get; init; } = 256; public int MaxVariableNamesPerDomain { get; init; } = 20000; public int MaxVariableListNamesPerDomain { get; init; } = 4096; - public bool ProbeReportAttributes { get; init; } = true; + + /// + /// Hard guard for one GetNameList continuation chain. It prevents a broken IED + /// from keeping discovery alive indefinitely while still preserving partial data. + /// + public int MaxNameListPages { get; init; } = 64; + + /// + /// Exact MMS domain identifiers that should be scheduled first after live domain + /// enumeration. This is suitable for SCL expected-domain hints. It never changes + /// the selected live domain set and extra live domains are still discovered. + /// + public IReadOnlyList PriorityDomains { get; init; } = Array.Empty(); + + /// + /// Report attribute reads are enrichment, not structural discovery. Keep them off + /// by default so a usable LD/LN/DO/DA tree is not blocked by many value reads. + /// + public bool ProbeReportAttributes { get; init; } public int MaxReportAttributeProbes { get; init; } = 32; + public bool ReadDataSetDirectories { get; init; } public int MaxDataSetDirectoryReads { get; init; } = 64; } @@ -32,10 +52,10 @@ public sealed class MmsSmartDiscoveryOptions public sealed partial class MmsClientSession { /// - /// Builds the same structural discovery result as the legacy discovery path, - /// but enumerates VMD domains once and pipelines independent per-domain - /// GetNameList chains. No eager per-leaf Read or GetVariableAccessAttributes - /// sweep is performed by this method. + /// Builds a structural MMS model with one VMD-domain enumeration and a fixed-size + /// worker pool for independent per-domain chains. Continuation pages inside one + /// chain are always sequential. Expected association loss is converted into a + /// partial result instead of allowing queued workers to crash Task.WhenAll. /// public async Task DiscoverSmartAsync( MmsSmartDiscoveryOptions? options = null, @@ -47,51 +67,45 @@ public async Task DiscoverSmartAsync( var maxDomains = Math.Clamp(options.MaxDomains, 1, 4096); var maxVariables = Math.Clamp(options.MaxVariableNamesPerDomain, 1, 100000); var maxVariableLists = Math.Clamp(options.MaxVariableListNamesPerDomain, 1, 20000); + var maxPages = Math.Clamp(options.MaxNameListPages, 1, 256); var effectiveWindow = ResolveSmartDiscoveryWindow(options); - var domainsResult = await GetNameListPagedAsync( + var domainsResult = await GetNameListPagedSmartAsync( MmsGetNameListObjectClass.Domain, null, + maxDomains, + maxPages, cancellationToken) .ConfigureAwait(false); - // GetNameListPagedAsync deliberately preserves collected names if an endpoint - // stops making pagination progress. MoreFollows therefore means partial, - // even when the legacy result has IsSuccess=true. - var domainListComplete = domainsResult.IsSuccess && !domainsResult.MoreFollows; - var domains = domainsResult.IsSuccess - ? domainsResult.Names - .Where(name => !string.IsNullOrWhiteSpace(name)) - .Distinct(StringComparer.OrdinalIgnoreCase) - .OrderBy(name => name, StringComparer.OrdinalIgnoreCase) - .Take(maxDomains) - .ToArray() + var publishedDomains = domainsResult.IsSuccess + ? MmsSmartDiscoveryPolicy.SelectPublishedDomains(domainsResult.Names, maxDomains) : Array.Empty(); + var scheduledDomains = MmsSmartDiscoveryPolicy.OrderDomainsForScheduling( + publishedDomains, + options.PriorityDomains); - using var gate = new SemaphoreSlim(effectiveWindow, effectiveWindow); - var chainTasks = new List>(domains.Length * 2); - foreach (var domain in domains) + var workItems = new SmartNameChainWorkItem[scheduledDomains.Length * 2]; + var workIndex = 0; + foreach (var domain in scheduledDomains) { - chainTasks.Add(ReadSmartNameChainAsync( + workItems[workIndex++] = new SmartNameChainWorkItem( domain, MmsGetNameListObjectClass.NamedVariable, - maxVariables, - gate, - cancellationToken)); - chainTasks.Add(ReadSmartNameChainAsync( + maxVariables); + workItems[workIndex++] = new SmartNameChainWorkItem( domain, MmsGetNameListObjectClass.NamedVariableList, - maxVariableLists, - gate, - cancellationToken)); + maxVariableLists); } - var chains = chainTasks.Count == 0 - ? Array.Empty() - : await Task.WhenAll(chainTasks).ConfigureAwait(false); + var chains = await RunSmartNameChainsAsync( + workItems, + effectiveWindow, + maxPages, + cancellationToken) + .ConfigureAwait(false); - // Final dictionaries are rebuilt in sorted domain order so completion timing - // never changes the published model ordering. var variablesByKey = chains .Where(chain => chain.ObjectClass == MmsGetNameListObjectClass.NamedVariable) .ToDictionary(chain => chain.Domain, StringComparer.OrdinalIgnoreCase); @@ -99,9 +113,10 @@ public async Task DiscoverSmartAsync( .Where(chain => chain.ObjectClass == MmsGetNameListObjectClass.NamedVariableList) .ToDictionary(chain => chain.Domain, StringComparer.OrdinalIgnoreCase); + // Publication order is derived from live evidence, not SCL scheduling hints. var domainVariables = new Dictionary>(StringComparer.OrdinalIgnoreCase); var domainVariableLists = new Dictionary>(StringComparer.OrdinalIgnoreCase); - foreach (var domain in domains) + foreach (var domain in publishedDomains) { domainVariables[domain] = variablesByKey.TryGetValue(domain, out var variables) ? variables.Names @@ -120,7 +135,7 @@ public async Task DiscoverSmartAsync( var inventory = MmsReportDiscoveryMapper.BuildInventory(snapshot); var iedDirectory = MmsIedModelDirectoryBuilder.Build(snapshot); - if (options.ProbeReportAttributes) + if (options.ProbeReportAttributes && IsMmsInitiated) { await EnrichReportInventoryAsync( inventory, @@ -129,7 +144,7 @@ await EnrichReportInventoryAsync( .ConfigureAwait(false); } - var dataSetReferences = options.ReadDataSetDirectories + var dataSetReferences = options.ReadDataSetDirectories && IsMmsInitiated ? inventory.DataSets .Select(dataSet => dataSet.Reference) .Where(reference => !string.IsNullOrWhiteSpace(reference)) @@ -138,16 +153,33 @@ await EnrichReportInventoryAsync( .ToArray() : Array.Empty(); - var dataSetDirectories = dataSetReferences.Length == 0 - ? Array.Empty() - : (await GetDataSetDirectoriesAsync( - dataSetReferences, - iedDirectory, - cancellationToken) - .ConfigureAwait(false)) - .ToArray(); + IReadOnlyList dataSetDirectories; + if (dataSetReferences.Length == 0) + { + dataSetDirectories = Array.Empty(); + } + else + { + try + { + dataSetDirectories = (await GetDataSetDirectoriesAsync( + dataSetReferences, + iedDirectory, + cancellationToken) + .ConfigureAwait(false)) + .ToArray(); + } + catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) + { + dataSetDirectories = Array.Empty(); + } + catch (Exception ex) when (IsExpectedSmartDiscoveryFault(ex)) + { + dataSetDirectories = Array.Empty(); + } + } - var failedChains = chains.Count(chain => !chain.IsComplete); + var incompleteChains = chains.Count(chain => !chain.IsComplete); var successfulDataSetDirectories = dataSetDirectories.Count(directory => directory.IsSuccess); var discoveredDataSetMembers = dataSetDirectories .Where(directory => directory.IsSuccess) @@ -156,16 +188,19 @@ await EnrichReportInventoryAsync( var negotiatedText = negotiated.HasValue ? negotiated.Value.ToString() : "unknown"; var domainStatus = !domainsResult.IsSuccess ? $"domain-list-failed={domainsResult.Message}" - : domainListComplete - ? $"domains={domains.Length}" - : $"domains-partial={domains.Length}, moreFollows=true"; + : domainsResult.MoreFollows + ? $"domains={publishedDomains.Length}, domain-list=partial" + : $"domains={publishedDomains.Length}, domain-list=complete"; var dataSetDirectorySummary = options.ReadDataSetDirectories - ? $"dataset directories={successfulDataSetDirectories}/{dataSetDirectories.Length}, dataset members={discoveredDataSetMembers}" - : "dataset directories=not requested"; + ? $"dataset directories={successfulDataSetDirectories}/{dataSetReferences.Length}, dataset members={discoveredDataSetMembers}" + : "dataset directories=deferred"; + var reportSummary = options.ProbeReportAttributes + ? "report enrichment=requested" + : "report enrichment=deferred"; LastDiscoveryAttemptSummary = - $"Smart discovery: {domainStatus}, chains={chains.Length}, incompleteChains={failedChains}, " + - $"window={effectiveWindow}, negotiatedCalling={negotiatedText}."; + $"Smart discovery: {domainStatus}, chains={chains.Length}, incompleteChains={incompleteChains}, " + + $"window={effectiveWindow}, negotiatedCalling={negotiatedText}, {reportSummary}."; return new MmsDiscoveryResult { @@ -184,54 +219,271 @@ await EnrichReportInventoryAsync( internal int ResolveSmartDiscoveryWindow(MmsSmartDiscoveryOptions options) { ArgumentNullException.ThrowIfNull(options); + return MmsSmartDiscoveryPolicy.ResolveWindow( + options.MaxConcurrentChains, + options.UnknownPeerMaxConcurrentChains, + LastNegotiatedCapabilities.MaxOutstandingCalling); + } - var requested = Math.Clamp(options.MaxConcurrentChains, 1, 32); - var unknownPeerCap = Math.Clamp(options.UnknownPeerMaxConcurrentChains, 1, 16); - var negotiated = LastNegotiatedCapabilities.MaxOutstandingCalling; + private async Task RunSmartNameChainsAsync( + IReadOnlyList workItems, + int maxConcurrency, + int maxPages, + CancellationToken cancellationToken) + { + if (workItems.Count == 0) + return Array.Empty(); + + var results = new SmartNameChainResult?[workItems.Count]; + var nextIndex = -1; + var workerCount = Math.Min(Math.Max(1, maxConcurrency), workItems.Count); + var workers = new Task[workerCount]; + + for (var worker = 0; worker < workerCount; worker++) + workers[worker] = WorkerAsync(); + + await Task.WhenAll(workers).ConfigureAwait(false); + + var materialized = new SmartNameChainResult[workItems.Count]; + for (var index = 0; index < workItems.Count; index++) + { + materialized[index] = results[index] ?? SmartNameChainResult.Skipped( + workItems[index], + "Skipped because the MMS association became unavailable before this chain started."); + } - if (negotiated is > 0) - return Math.Max(1, Math.Min(requested, negotiated.Value)); + return materialized; - return Math.Min(requested, unknownPeerCap); + async Task WorkerAsync() + { + while (true) + { + cancellationToken.ThrowIfCancellationRequested(); + if (!IsMmsInitiated) + return; + + var index = Interlocked.Increment(ref nextIndex); + if (index >= workItems.Count) + return; + + var item = workItems[index]; + results[index] = await ReadSmartNameChainSafeAsync( + item, + maxPages, + cancellationToken) + .ConfigureAwait(false); + + if (!IsMmsInitiated) + return; + } + } } - private async Task ReadSmartNameChainAsync( - string domain, - MmsGetNameListObjectClass objectClass, - int maxNames, - SemaphoreSlim gate, + private async Task ReadSmartNameChainSafeAsync( + SmartNameChainWorkItem item, + int maxPages, CancellationToken cancellationToken) { - await gate.WaitAsync(cancellationToken).ConfigureAwait(false); + if (!IsMmsInitiated) + return SmartNameChainResult.Skipped(item, "MMS association is unavailable."); + try { - var result = await GetNameListPagedAsync(objectClass, domain, cancellationToken).ConfigureAwait(false); + var result = await GetNameListPagedSmartAsync( + item.ObjectClass, + item.Domain, + item.MaxNames, + maxPages, + cancellationToken) + .ConfigureAwait(false); + var names = result.Names .Where(name => !string.IsNullOrWhiteSpace(name)) .Distinct(StringComparer.OrdinalIgnoreCase) .OrderBy(name => name, StringComparer.OrdinalIgnoreCase) - .Take(maxNames) + .Take(item.MaxNames) .ToArray(); return new SmartNameChainResult( - domain, - objectClass, + item.Domain, + item.ObjectClass, result.IsSuccess && !result.MoreFollows, names, - result.MoreFollows - ? $"Partial GetNameList chain: IED still reported moreFollows. {result.Message}" - : result.Message); + result.Message); + } + catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) + { + return SmartNameChainResult.Skipped( + item, + "The receive pump stopped while this discovery chain was pending."); + } + catch (Exception ex) when (IsExpectedSmartDiscoveryFault(ex)) + { + return SmartNameChainResult.Skipped( + item, + $"Discovery chain stopped safely after association fault: {ex.GetType().Name}: {ex.Message}"); + } + } + + /// + /// Smart-path pager with O(n) boundary de-duplication plus explicit page, name, + /// and continuation-token guards. It intentionally does not use the legacy + /// List.Contains de-duplication loop on large directories. + /// + private async Task GetNameListPagedSmartAsync( + MmsGetNameListObjectClass objectClass, + string? domainId, + int maxNames, + int maxPages, + CancellationToken cancellationToken) + { + EnsureMmsReady(); + + var boundedNames = Math.Clamp(maxNames, 1, 100000); + var boundedPages = Math.Clamp(maxPages, 1, 256); + var names = new List(Math.Min(boundedNames, 1024)); + var seenNames = new HashSet(StringComparer.OrdinalIgnoreCase); + var seenContinuationTokens = new HashSet(StringComparer.OrdinalIgnoreCase); + var continueAfter = string.Empty; + var page = 0; + var incomplete = false; + var stopReason = string.Empty; + MmsNameListResult? last = null; + + while (page < boundedPages) + { + cancellationToken.ThrowIfCancellationRequested(); + page++; + + var invokeId = NextInvokeId(); + var request = MmsGetNameListRequest.Build( + invokeId, + objectClass, + domainId, + string.IsNullOrWhiteSpace(continueAfter) ? null : continueAfter); + LastDiscoveryRequestHex = HexDump.ToCompactString(request); + + try + { + var response = await SendConfirmedPresentationPayloadAsync( + request, + invokeId, + cancellationToken) + .ConfigureAwait(false); + last = MmsGetNameListResponseDecoder.Decode(response, invokeId); + LastDiscoveryResponseHex = last.ResponseHexPreview; + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) when (IsExpectedSmartDiscoveryFault(ex)) + { + await MarkProtocolFaultAsync().ConfigureAwait(false); + return new MmsNameListResult + { + IsSuccess = false, + Names = names, + MoreFollows = false, + Message = $"GetNameList {objectClass}/{domainId ?? "VMD"} transport fault on page {page}: {ex.GetType().Name}: {ex.Message}", + ResponseHexPreview = LastDiscoveryResponseHex + }; + } + + if (!last.IsSuccess) + { + return new MmsNameListResult + { + IsSuccess = false, + Names = names, + MoreFollows = false, + Message = last.Message, + ResponseHexPreview = last.ResponseHexPreview + }; + } + + var before = names.Count; + foreach (var rawName in last.Names) + { + if (string.IsNullOrWhiteSpace(rawName)) + continue; + + var name = rawName.Trim(); + if (seenNames.Add(name) && names.Count < boundedNames) + names.Add(name); + } + + var newCount = names.Count - before; + if (!last.MoreFollows) + break; + + if (names.Count >= boundedNames) + { + incomplete = true; + stopReason = $"name limit {boundedNames} reached"; + break; + } + + if (newCount <= 0) + { + incomplete = true; + stopReason = "IED returned no new names while moreFollows remained true"; + break; + } + + var nextContinueAfter = last.Names.LastOrDefault()?.Trim() ?? string.Empty; + if (string.IsNullOrWhiteSpace(nextContinueAfter)) + { + incomplete = true; + stopReason = "IED returned moreFollows without a continuation token"; + break; + } + + if (!seenContinuationTokens.Add(nextContinueAfter)) + { + incomplete = true; + stopReason = $"continuation token repeated ({nextContinueAfter})"; + break; + } + + continueAfter = nextContinueAfter; } - finally + + if (page >= boundedPages && last?.MoreFollows == true) { - gate.Release(); + incomplete = true; + stopReason = $"page limit {boundedPages} reached"; } + + var moreFollows = incomplete || (last?.MoreFollows ?? false); + var suffix = string.IsNullOrWhiteSpace(stopReason) ? string.Empty : $" Stopped safely: {stopReason}."; + return new MmsNameListResult + { + IsSuccess = true, + Names = names, + MoreFollows = moreFollows, + MoreFollowsWasPresent = last?.MoreFollowsWasPresent ?? false, + Message = $"Smart GetNameList {objectClass}/{domainId ?? "VMD"}: names={names.Count}, pages={page}, complete={!moreFollows}.{suffix}", + ResponseHexPreview = last?.ResponseHexPreview ?? string.Empty + }; } + private static bool IsExpectedSmartDiscoveryFault(Exception ex) + => ex is IOException or InvalidDataException or ObjectDisposedException or InvalidOperationException; + + private sealed record SmartNameChainWorkItem( + string Domain, + MmsGetNameListObjectClass ObjectClass, + int MaxNames); + private sealed record SmartNameChainResult( string Domain, MmsGetNameListObjectClass ObjectClass, bool IsComplete, IReadOnlyList Names, - string Message); + string Message) + { + public static SmartNameChainResult Skipped(SmartNameChainWorkItem item, string message) + => new(item.Domain, item.ObjectClass, false, Array.Empty(), message); + } } From 7be557de2b4062dbfce0f0a65e12002af8aff2bc Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 16 Sep 2026 19:32:21 +0700 Subject: [PATCH 08/66] perf(discovery): use LN-first adaptive type probe ladder --- ...ntSession.SmartVariableAccessAttributes.cs | 236 +++++++++++++----- 1 file changed, 180 insertions(+), 56 deletions(-) diff --git a/src/AR.Iec61850/Mms/MmsClientSession.SmartVariableAccessAttributes.cs b/src/AR.Iec61850/Mms/MmsClientSession.SmartVariableAccessAttributes.cs index 7132f960..7d5c2be7 100644 --- a/src/AR.Iec61850/Mms/MmsClientSession.SmartVariableAccessAttributes.cs +++ b/src/AR.Iec61850/Mms/MmsClientSession.SmartVariableAccessAttributes.cs @@ -3,109 +3,233 @@ namespace AR.Iec61850.Mms; public sealed partial class MmsClientSession { /// - /// Reads variable type metadata structure-first. One GetVariableAccessAttributes - /// request is issued for each IEC 61850 LN/FC/data-object root. Leaf probes are - /// used only when the root request fails or does not return a structured type. - /// This preserves exact type discovery while avoiding an eager request for every - /// discovered leaf on IEDs that expose the normal MMS structure hierarchy. + /// Convenience entry point for live-only callers. Canonical discovery code should + /// prefer the overload that supplies logical-node root candidates from its semantic + /// probe planner. The fallback ladder is LN root -> unresolved DO root -> unresolved + /// leaf, so normal structured IEDs need only roughly one GVA per logical node. + /// + public Task> GetVariableAccessAttributesSmartAsync( + MmsIedModelDirectory directory, + MmsSmartDiscoveryOptions? options = null, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(directory); + var logicalNodeRoots = directory.LogicalDevices.Values + .OrderBy(device => device.Name, StringComparer.OrdinalIgnoreCase) + .SelectMany(device => device.LogicalNodes.Values + .OrderBy(node => node.Name, StringComparer.OrdinalIgnoreCase) + .Select(node => new MmsObjectReference(device.Name, node.Name, string.Empty))) + .ToArray(); + + return GetVariableAccessAttributesSmartAsync( + directory, + logicalNodeRoots, + options, + cancellationToken); + } + + /// + /// Executes a bounded, coverage-aware type discovery ladder. A successful parent + /// TypeSpecification suppresses all descendant probes it can actually prove; only + /// unresolved branches descend to DO roots and finally exact leaves. /// public async Task> GetVariableAccessAttributesSmartAsync( MmsIedModelDirectory directory, + IEnumerable logicalNodeRootCandidates, MmsSmartDiscoveryOptions? options = null, CancellationToken cancellationToken = default) { EnsureMmsReady(); ArgumentNullException.ThrowIfNull(directory); + ArgumentNullException.ThrowIfNull(logicalNodeRootCandidates); options ??= new MmsSmartDiscoveryOptions(); - var groups = directory.Points - .Where(point => !string.IsNullOrWhiteSpace(point.Domain) && !string.IsNullOrWhiteSpace(point.MmsItemName)) - .GroupBy( - point => BuildSmartTypeRoot(point), - MmsObjectReferenceKeyComparer.Instance) - .OrderBy(group => group.Key.Domain, StringComparer.OrdinalIgnoreCase) - .ThenBy(group => group.Key.Item, StringComparer.OrdinalIgnoreCase) + var points = directory.Points + .Where(point => !string.IsNullOrWhiteSpace(point.Domain) && + !string.IsNullOrWhiteSpace(point.LogicalNode) && + !string.IsNullOrWhiteSpace(point.MmsItemName)) + .OrderBy(point => point.Domain, StringComparer.OrdinalIgnoreCase) + .ThenBy(point => point.LogicalNode, StringComparer.OrdinalIgnoreCase) + .ThenBy(point => point.MmsItemName, StringComparer.OrdinalIgnoreCase) .ToArray(); + if (points.Length == 0) + return Array.Empty(); - if (groups.Length == 0) + var logicalNodeRoots = logicalNodeRootCandidates + .Where(reference => !string.IsNullOrWhiteSpace(reference.Domain) && + !string.IsNullOrWhiteSpace(reference.Item)) + .Distinct(MmsObjectReferenceKeyComparer.Instance) + .OrderBy(reference => reference.Domain, StringComparer.OrdinalIgnoreCase) + .ThenBy(reference => reference.Item, StringComparer.OrdinalIgnoreCase) + .ToArray(); + if (logicalNodeRoots.Length == 0) return Array.Empty(); var window = ResolveSmartDiscoveryWindow(options); - using var gate = new SemaphoreSlim(window, window); + var results = new List(logicalNodeRoots.Length); - var rootTasks = groups - .Select(group => ReadVariableAttributesBoundedAsync(group.Key, gate, cancellationToken)) - .ToArray(); - var rootResults = await Task.WhenAll(rootTasks).ConfigureAwait(false); + var logicalNodeResults = await RunVariableAttributeBatchAsync( + logicalNodeRoots, + window, + cancellationToken) + .ConfigureAwait(false); + results.AddRange(logicalNodeResults); - var results = new List(rootResults.Length); - results.AddRange(rootResults); + var logicalNodeIndex = logicalNodeResults + .GroupBy(result => result.Reference, MmsObjectReferenceKeyComparer.Instance) + .ToDictionary(group => group.Key, group => group.Last(), MmsObjectReferenceKeyComparer.Instance); - var fallbackReferences = new List(); - for (var index = 0; index < groups.Length; index++) + var unresolvedAfterLogicalNode = new List(); + foreach (var point in points) { - var group = groups[index]; - var rootResult = rootResults[index]; - var hasDescendants = group.Any(point => - !point.MmsItemName.Equals(group.Key.Item, StringComparison.OrdinalIgnoreCase)); - var rootDescribesHierarchy = rootResult.IsSuccess && - (!hasDescendants || rootResult.TypeSpecification?.Children.Count > 0); - - if (rootDescribesHierarchy) + var logicalNodeRoot = new MmsObjectReference(point.Domain, point.LogicalNode, string.Empty); + if (logicalNodeIndex.TryGetValue(logicalNodeRoot, out var rootResult) && + MmsSmartTypeProbePolicy.Covers(rootResult, point.MmsItemName)) + { continue; + } - foreach (var point in group) + unresolvedAfterLogicalNode.Add(point); + } + + if (unresolvedAfterLogicalNode.Count == 0 || !IsMmsInitiated) + return results; + + var dataObjectRoots = unresolvedAfterLogicalNode + .Select(MmsSmartTypeProbePolicy.BuildDataObjectRoot) + .Where(reference => !string.IsNullOrWhiteSpace(reference.Item)) + .Distinct(MmsObjectReferenceKeyComparer.Instance) + .OrderBy(reference => reference.Domain, StringComparer.OrdinalIgnoreCase) + .ThenBy(reference => reference.Item, StringComparer.OrdinalIgnoreCase) + .ToArray(); + + var dataObjectResults = await RunVariableAttributeBatchAsync( + dataObjectRoots, + window, + cancellationToken) + .ConfigureAwait(false); + results.AddRange(dataObjectResults); + + var dataObjectIndex = dataObjectResults + .GroupBy(result => result.Reference, MmsObjectReferenceKeyComparer.Instance) + .ToDictionary(group => group.Key, group => group.Last(), MmsObjectReferenceKeyComparer.Instance); + + var leafFallbacks = new List(); + foreach (var point in unresolvedAfterLogicalNode) + { + var dataObjectRoot = MmsSmartTypeProbePolicy.BuildDataObjectRoot(point); + if (dataObjectIndex.TryGetValue(dataObjectRoot, out var rootResult) && + MmsSmartTypeProbePolicy.Covers(rootResult, point.MmsItemName)) { - var reference = point.ToObjectReference(); - if (reference.Item.Equals(group.Key.Item, StringComparison.OrdinalIgnoreCase)) - continue; - fallbackReferences.Add(reference); + continue; } + + leafFallbacks.Add(point.ToObjectReference()); } - var distinctFallbacks = fallbackReferences + var distinctLeafFallbacks = leafFallbacks .Distinct(MmsObjectReferenceKeyComparer.Instance) .OrderBy(reference => reference.Domain, StringComparer.OrdinalIgnoreCase) .ThenBy(reference => reference.Item, StringComparer.OrdinalIgnoreCase) .ToArray(); - if (distinctFallbacks.Length > 0 && IsMmsInitiated) + if (distinctLeafFallbacks.Length > 0 && IsMmsInitiated) { - var fallbackTasks = distinctFallbacks - .Select(reference => ReadVariableAttributesBoundedAsync(reference, gate, cancellationToken)) - .ToArray(); - results.AddRange(await Task.WhenAll(fallbackTasks).ConfigureAwait(false)); + results.AddRange(await RunVariableAttributeBatchAsync( + distinctLeafFallbacks, + window, + cancellationToken) + .ConfigureAwait(false)); } return results; } - private async Task ReadVariableAttributesBoundedAsync( + private async Task RunVariableAttributeBatchAsync( + IReadOnlyList references, + int maxConcurrency, + CancellationToken cancellationToken) + { + if (references.Count == 0) + return Array.Empty(); + + var results = new MmsVariableAccessAttributesResult?[references.Count]; + var nextIndex = -1; + var workerCount = Math.Min(Math.Max(1, maxConcurrency), references.Count); + var workers = new Task[workerCount]; + + for (var worker = 0; worker < workerCount; worker++) + workers[worker] = WorkerAsync(); + + await Task.WhenAll(workers).ConfigureAwait(false); + + var materialized = new MmsVariableAccessAttributesResult[references.Count]; + for (var index = 0; index < references.Count; index++) + { + materialized[index] = results[index] ?? BuildUnavailableVariableTypeResult( + references[index], + "Skipped because the MMS association became unavailable before this probe started."); + } + + return materialized; + + async Task WorkerAsync() + { + while (true) + { + cancellationToken.ThrowIfCancellationRequested(); + if (!IsMmsInitiated) + return; + + var index = Interlocked.Increment(ref nextIndex); + if (index >= references.Count) + return; + + var reference = references[index]; + results[index] = await ReadVariableAttributesSafeAsync(reference, cancellationToken) + .ConfigureAwait(false); + + if (!IsMmsInitiated) + return; + } + } + } + + private async Task ReadVariableAttributesSafeAsync( MmsObjectReference reference, - SemaphoreSlim gate, CancellationToken cancellationToken) { - await gate.WaitAsync(cancellationToken).ConfigureAwait(false); + if (!IsMmsInitiated) + return BuildUnavailableVariableTypeResult(reference, "MMS association is unavailable."); + try { return await GetVariableAccessAttributesAsync(reference, cancellationToken).ConfigureAwait(false); } - finally + catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) + { + return BuildUnavailableVariableTypeResult( + reference, + "The receive pump stopped while this type probe was pending."); + } + catch (Exception ex) when (ex is IOException or InvalidDataException or ObjectDisposedException or InvalidOperationException) { - gate.Release(); + return BuildUnavailableVariableTypeResult( + reference, + $"Type probe stopped safely after association fault: {ex.GetType().Name}: {ex.Message}"); } } - private static MmsObjectReference BuildSmartTypeRoot(MmsFcResolvedPoint point) - { - var parts = point.MmsItemName.Split( - '$', - StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); - var item = parts.Length >= 3 - ? string.Join('$', parts.Take(3)) - : point.MmsItemName; - return new MmsObjectReference(point.Domain, item, point.FunctionalConstraint); - } + private static MmsVariableAccessAttributesResult BuildUnavailableVariableTypeResult( + MmsObjectReference reference, + string message) + => new() + { + IsSuccess = false, + Reference = reference, + Message = message, + Source = "SmartTypeProbe" + }; private sealed class MmsObjectReferenceKeyComparer : IEqualityComparer { From b121156d10816446096311e7d58d08e03cdc5a0a Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 16 Sep 2026 19:32:48 +0700 Subject: [PATCH 09/66] refactor(discovery): route smart GVA through canonical planner --- .../Discovery/LiveIedVariableTypeHierarchy.cs | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/src/AR.Iec61850/Discovery/LiveIedVariableTypeHierarchy.cs b/src/AR.Iec61850/Discovery/LiveIedVariableTypeHierarchy.cs index c8863aff..021f2709 100644 --- a/src/AR.Iec61850/Discovery/LiveIedVariableTypeHierarchy.cs +++ b/src/AR.Iec61850/Discovery/LiveIedVariableTypeHierarchy.cs @@ -23,6 +23,31 @@ public static IReadOnlyList BuildLogicalNodeRootCandidates(M } } +/// +/// Canonical live-discovery entry point for bounded type enrichment. Planning stays +/// in the Discovery layer while the MMS session only executes the supplied evidence +/// probes. This prevents SCL/live semantic logic from leaking into the wire layer. +/// +public static class LiveIedVariableTypeProbeExecutor +{ + public static Task> ProbeSmartAsync( + MmsClientSession session, + MmsIedModelDirectory directory, + MmsSmartDiscoveryOptions? options = null, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(session); + ArgumentNullException.ThrowIfNull(directory); + + var roots = LiveIedVariableTypeProbePlanner.BuildLogicalNodeRootCandidates(directory); + return session.GetVariableAccessAttributesSmartAsync( + directory, + roots, + options, + cancellationToken); + } +} + internal sealed class LiveIedVariableTypeHierarchyIndex { private readonly Dictionary _byMmsReference = From 06809a54e1e6b9f7b569cfe1f29bafd527f0070d Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 16 Sep 2026 19:33:30 +0700 Subject: [PATCH 10/66] fix(discovery): collapse case-only domain collisions safely --- src/AR.Iec61850/Mms/MmsSmartDiscoveryPolicy.cs | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/AR.Iec61850/Mms/MmsSmartDiscoveryPolicy.cs b/src/AR.Iec61850/Mms/MmsSmartDiscoveryPolicy.cs index 1083da64..0cad29a7 100644 --- a/src/AR.Iec61850/Mms/MmsSmartDiscoveryPolicy.cs +++ b/src/AR.Iec61850/Mms/MmsSmartDiscoveryPolicy.cs @@ -21,7 +21,9 @@ public static int ResolveWindow(int requested, int unknownPeerCap, int? negotiat /// /// Selects the live domain set independently from any SCL hint. This is important: /// SCL may prioritize online work, but it must never manufacture or suppress live - /// MMS evidence. + /// MMS evidence. Case-only collisions are collapsed deterministically because the + /// existing MMS directory model is case-insensitive and cannot represent both + /// without a dictionary collision. /// public static string[] SelectPublishedDomains(IEnumerable observedDomains, int maxDomains) { @@ -30,8 +32,10 @@ public static string[] SelectPublishedDomains(IEnumerable observedDomain return observedDomains .Where(domain => !string.IsNullOrWhiteSpace(domain)) .Select(domain => domain.Trim()) - .Distinct(StringComparer.Ordinal) - .OrderBy(domain => domain, StringComparer.Ordinal) + .GroupBy(domain => domain, StringComparer.OrdinalIgnoreCase) + .Select(group => group.OrderBy(domain => domain, StringComparer.Ordinal).First()) + .OrderBy(domain => domain, StringComparer.OrdinalIgnoreCase) + .ThenBy(domain => domain, StringComparer.Ordinal) .Take(Math.Clamp(maxDomains, 1, 4096)) .ToArray(); } From 102e777420a6afb2cd16737c9f7d584374f4f155 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 16 Sep 2026 19:33:58 +0700 Subject: [PATCH 11/66] test(discovery): lock smart scheduling and probe invariants --- .../Mms/MmsSmartDiscoveryPolicyTests.cs | 144 ++++++++++++++++++ 1 file changed, 144 insertions(+) create mode 100644 tests/AR.Iec61850.Tests/Mms/MmsSmartDiscoveryPolicyTests.cs diff --git a/tests/AR.Iec61850.Tests/Mms/MmsSmartDiscoveryPolicyTests.cs b/tests/AR.Iec61850.Tests/Mms/MmsSmartDiscoveryPolicyTests.cs new file mode 100644 index 00000000..437dc7d4 --- /dev/null +++ b/tests/AR.Iec61850.Tests/Mms/MmsSmartDiscoveryPolicyTests.cs @@ -0,0 +1,144 @@ +using AR.Iec61850.Discovery; +using AR.Iec61850.Mms; + +namespace AR.Iec61850.Tests.Mms; + +public sealed class MmsSmartDiscoveryPolicyTests +{ + [Fact] + public void Defaults_KeepExpensiveEnrichmentOffCriticalPath() + { + var options = new MmsSmartDiscoveryOptions(); + + Assert.False(options.ProbeReportAttributes); + Assert.False(options.ReadDataSetDirectories); + Assert.Equal(8, options.MaxConcurrentChains); + Assert.Equal(4, options.UnknownPeerMaxConcurrentChains); + Assert.Equal(64, options.MaxNameListPages); + } + + [Fact] + public void ResolveWindow_RespectsNegotiatedLimitAndConservativeUnknownFallback() + { + Assert.Equal(8, MmsSmartDiscoveryPolicy.ResolveWindow(8, 4, 10)); + Assert.Equal(10, MmsSmartDiscoveryPolicy.ResolveWindow(16, 4, 10)); + Assert.Equal(4, MmsSmartDiscoveryPolicy.ResolveWindow(8, 4, null)); + Assert.Equal(1, MmsSmartDiscoveryPolicy.ResolveWindow(0, 0, null)); + } + + [Fact] + public void DomainPriority_ReordersOnlyAlreadyObservedDomains() + { + var selected = MmsSmartDiscoveryPolicy.SelectPublishedDomains( + ["LD1", "EXTRA", "LD0"], + maxDomains: 256); + + var scheduled = MmsSmartDiscoveryPolicy.OrderDomainsForScheduling( + selected, + ["LD1", "MISSING", "LD1"]); + + Assert.Equal(["EXTRA", "LD0", "LD1"], selected); + Assert.Equal(["LD1", "EXTRA", "LD0"], scheduled); + Assert.Equal( + selected.OrderBy(x => x, StringComparer.Ordinal), + scheduled.OrderBy(x => x, StringComparer.Ordinal)); + } + + [Fact] + public void DomainSelection_CollapsesCaseOnlyCollisionBeforeDictionaryPublication() + { + var selected = MmsSmartDiscoveryPolicy.SelectPublishedDomains( + ["ld0", "LD0", "LD1", "LD1"], + maxDomains: 256); + + Assert.Equal(["LD0", "LD1"], selected); + } + + [Fact] + public void LogicalNodePlanner_UsesOneCandidatePerLogicalNodeNotPerLeaf() + { + var directory = BuildDirectory(); + + var roots = LiveIedVariableTypeProbePlanner.BuildLogicalNodeRootCandidates(directory); + + Assert.Equal(2, roots.Count); + Assert.Contains(roots, root => root.Domain == "LD0" && root.Item == "MMXU1"); + Assert.Contains(roots, root => root.Domain == "LD0" && root.Item == "XCBR1"); + } + + [Fact] + public void TypeCoverage_UsesParentHierarchyAndRejectsUnknownBranch() + { + var root = new MmsVariableAccessAttributesResult + { + IsSuccess = true, + Reference = new MmsObjectReference("LD0", "MMXU1", string.Empty), + TypeSpecification = Node( + "", + Node("MX", + Node("PhV", + Node("phsA", + Node("cVal", + Node("mag", + Node("f"))))))) + }; + + Assert.True(MmsSmartTypeProbePolicy.Covers( + root, + "MMXU1$MX$PhV$phsA$cVal$mag$f")); + Assert.False(MmsSmartTypeProbePolicy.Covers( + root, + "MMXU1$MX$PhV$phsA$q")); + Assert.False(MmsSmartTypeProbePolicy.Covers( + root, + "XCBR1$ST$Pos$stVal")); + } + + [Fact] + public void DataObjectFallbackRoot_StopsAtLnFcDo() + { + var point = new MmsFcResolvedPoint + { + Domain = "LD0", + LogicalNode = "MMXU1", + FunctionalConstraint = "MX", + DataObjectPath = "PhV.phsA.cVal.mag.f", + MmsItemName = "MMXU1$MX$PhV$phsA$cVal$mag$f" + }; + + var root = MmsSmartTypeProbePolicy.BuildDataObjectRoot(point); + + Assert.Equal("LD0", root.Domain); + Assert.Equal("MMXU1$MX$PhV", root.Item); + Assert.Equal("MX", root.FunctionalConstraint); + } + + private static MmsIedModelDirectory BuildDirectory() + { + var snapshot = new MmsDiscoverySnapshot + { + DomainVariables = new Dictionary>(StringComparer.OrdinalIgnoreCase) + { + ["LD0"] = + [ + "XCBR1$ST$Pos$stVal", + "XCBR1$ST$Pos$q", + "MMXU1$MX$PhV$phsA$cVal$mag$f", + "MMXU1$MX$A$phsA$cVal$mag$f" + ] + } + }; + + return MmsIedModelDirectoryBuilder.Build(snapshot); + } + + private static MmsTypeSpecificationNode Node( + string name, + params MmsTypeSpecificationNode[] children) + => new() + { + Name = name, + MmsType = children.Length == 0 ? "floating-point" : "structure", + Children = children + }; +} From dc51af421fa81bd3ac985ba76734f3df189c6d5f Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 16 Sep 2026 19:36:49 +0700 Subject: [PATCH 12/66] fix(discovery): preserve partial evidence and harden enrichment --- .../Mms/MmsClientSession.SmartDiscovery.cs | 56 ++++++++++++++----- 1 file changed, 43 insertions(+), 13 deletions(-) diff --git a/src/AR.Iec61850/Mms/MmsClientSession.SmartDiscovery.cs b/src/AR.Iec61850/Mms/MmsClientSession.SmartDiscovery.cs index 0d2426b4..83e78e62 100644 --- a/src/AR.Iec61850/Mms/MmsClientSession.SmartDiscovery.cs +++ b/src/AR.Iec61850/Mms/MmsClientSession.SmartDiscovery.cs @@ -78,9 +78,12 @@ public async Task DiscoverSmartAsync( cancellationToken) .ConfigureAwait(false); - var publishedDomains = domainsResult.IsSuccess - ? MmsSmartDiscoveryPolicy.SelectPublishedDomains(domainsResult.Names, maxDomains) - : Array.Empty(); + // Preserve valid domain evidence even when a later continuation page fails. + // The status remains partial/failed in diagnostics, but a recoverable fault + // must not erase already-observed IED structure. + var publishedDomains = MmsSmartDiscoveryPolicy.SelectPublishedDomains( + domainsResult.Names, + maxDomains); var scheduledDomains = MmsSmartDiscoveryPolicy.OrderDomainsForScheduling( publishedDomains, options.PriorityDomains); @@ -135,13 +138,25 @@ public async Task DiscoverSmartAsync( var inventory = MmsReportDiscoveryMapper.BuildInventory(snapshot); var iedDirectory = MmsIedModelDirectoryBuilder.Build(snapshot); + var reportEnrichmentInterrupted = false; if (options.ProbeReportAttributes && IsMmsInitiated) { - await EnrichReportInventoryAsync( - inventory, - Math.Max(0, options.MaxReportAttributeProbes), - cancellationToken) - .ConfigureAwait(false); + try + { + await EnrichReportInventoryAsync( + inventory, + Math.Max(0, options.MaxReportAttributeProbes), + cancellationToken) + .ConfigureAwait(false); + } + catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) + { + reportEnrichmentInterrupted = true; + } + catch (Exception ex) when (IsExpectedSmartDiscoveryFault(ex)) + { + reportEnrichmentInterrupted = true; + } } var dataSetReferences = options.ReadDataSetDirectories && IsMmsInitiated @@ -187,16 +202,18 @@ await EnrichReportInventoryAsync( var negotiated = LastNegotiatedCapabilities.MaxOutstandingCalling; var negotiatedText = negotiated.HasValue ? negotiated.Value.ToString() : "unknown"; var domainStatus = !domainsResult.IsSuccess - ? $"domain-list-failed={domainsResult.Message}" + ? $"domains={publishedDomains.Length}, domain-list=failed-after-partial:{domainsResult.Message}" : domainsResult.MoreFollows ? $"domains={publishedDomains.Length}, domain-list=partial" : $"domains={publishedDomains.Length}, domain-list=complete"; var dataSetDirectorySummary = options.ReadDataSetDirectories ? $"dataset directories={successfulDataSetDirectories}/{dataSetReferences.Length}, dataset members={discoveredDataSetMembers}" : "dataset directories=deferred"; - var reportSummary = options.ProbeReportAttributes - ? "report enrichment=requested" - : "report enrichment=deferred"; + var reportSummary = !options.ProbeReportAttributes + ? "report enrichment=deferred" + : reportEnrichmentInterrupted + ? "report enrichment=partial" + : "report enrichment=completed"; LastDiscoveryAttemptSummary = $"Smart discovery: {domainStatus}, chains={chains.Length}, incompleteChains={incompleteChains}, " + @@ -403,17 +420,30 @@ private async Task GetNameListPagedSmartAsync( } var before = names.Count; + var truncatedThisPage = false; foreach (var rawName in last.Names) { if (string.IsNullOrWhiteSpace(rawName)) continue; var name = rawName.Trim(); - if (seenNames.Add(name) && names.Count < boundedNames) + if (!seenNames.Add(name)) + continue; + + if (names.Count < boundedNames) names.Add(name); + else + truncatedThisPage = true; } var newCount = names.Count - before; + if (truncatedThisPage) + { + incomplete = true; + stopReason = $"name limit {boundedNames} reached inside page {page}"; + break; + } + if (!last.MoreFollows) break; From 350ed01534fb15de392fa17e7a7f2c08cb690ba5 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 16 Sep 2026 19:37:08 +0700 Subject: [PATCH 13/66] perf(transport): bound concurrent TPKT send allocation --- src/AR.Iec61850/Osi/TpktClient.cs | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/src/AR.Iec61850/Osi/TpktClient.cs b/src/AR.Iec61850/Osi/TpktClient.cs index a78e8a7d..a4527272 100644 --- a/src/AR.Iec61850/Osi/TpktClient.cs +++ b/src/AR.Iec61850/Osi/TpktClient.cs @@ -47,17 +47,20 @@ public async Task SendTpktAsync(ReadOnlyMemory payload, CancellationToken if (payload.Length > ushort.MaxValue - 4) throw new ArgumentOutOfRangeException(nameof(payload), "TPKT payload is too large."); - var frame = new byte[payload.Length + 4]; - frame[0] = 0x03; - frame[1] = 0x00; - frame[2] = (byte)(frame.Length >> 8); - frame[3] = (byte)(frame.Length & 0xFF); - payload.CopyTo(frame.AsMemory(4)); - + // Serialize only complete frame writes. Building the frame inside the gate keeps + // peak allocation bounded to one outbound TPKT frame even when many confirmed + // MMS operations are outstanding concurrently. await _sendGate.WaitAsync(cancellationToken).ConfigureAwait(false); try { var stream = _stream ?? throw new InvalidOperationException("TPKT stream is not connected."); + var frame = new byte[payload.Length + 4]; + frame[0] = 0x03; + frame[1] = 0x00; + frame[2] = (byte)(frame.Length >> 8); + frame[3] = (byte)(frame.Length & 0xFF); + payload.CopyTo(frame.AsMemory(4)); + await stream.WriteAsync(frame, cancellationToken).ConfigureAwait(false); } finally From 0d6f3aa29a461884f5c0db922b16f9ec237c132b Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 16 Sep 2026 19:37:52 +0700 Subject: [PATCH 14/66] docs(discovery): document bounded SCL-aware smart pipeline --- docs/SMART_MMS_DISCOVERY.md | 82 ++++++++++++++++++++++++++----------- 1 file changed, 58 insertions(+), 24 deletions(-) diff --git a/docs/SMART_MMS_DISCOVERY.md b/docs/SMART_MMS_DISCOVERY.md index 085d24e4..2b188cdf 100644 --- a/docs/SMART_MMS_DISCOVERY.md +++ b/docs/SMART_MMS_DISCOVERY.md @@ -1,6 +1,6 @@ # Smart MMS discovery -`MmsClientSession.DiscoverSmartAsync` is the capture-informed discovery path for fast, deterministic IEC 61850 model construction. +`MmsClientSession.DiscoverSmartAsync` is the capture-informed discovery path for fast, deterministic IEC 61850 model construction. It follows the repository's canonical contract: live MMS provides online evidence, SCL may prioritize and validate that evidence, and both feed the same canonical model rather than separate semantic trees. ## Design invariants @@ -10,47 +10,79 @@ - Independent `(domain, object-class)` GetNameList chains may be outstanding concurrently. - Continuation pages inside one GetNameList chain are always sequential. - The effective discovery window is capped by the peer's negotiated `maxOutstandingCalling` when available. Unknown peers use a conservative fallback cap. -- Published dictionaries are rebuilt in sorted domain order, so concurrent completion never changes model ordering. -- A failed domain chain produces an empty branch while successful branches remain available in the returned structural model. +- A fixed-size worker pool is used instead of creating one Task per domain/service pair, keeping scheduling and allocation bounded on large IEDs. +- Published dictionaries are rebuilt from live evidence in deterministic domain order, so concurrent completion and SCL priority hints never change model semantics. +- Valid evidence from completed pages/chains is retained when a later page or another chain fails. +- Expected receive-pump/association loss during concurrent work becomes a partial result instead of an unhandled `Task.WhenAll` failure. - Initial smart discovery does not perform an eager per-leaf Read or GetVariableAccessAttributes sweep. +- Report attribute reads and DataSet directory reads are deferred by default because they are enrichment, not prerequisites for the first usable LD/LN/DO/DA tree. + +## Bounded pagination + +The smart pager is intentionally separate from the compatibility pager. It uses an O(n) `HashSet` boundary de-duplication path and has explicit guards for: + +- maximum pages per GetNameList chain; +- maximum names per chain; +- no-new-name pages while `moreFollows` remains true; +- empty continuation tokens; +- repeated continuation tokens/cycles. + +If a guard fires, already-decoded names remain available and the chain is marked incomplete. The engine does not keep asking the same IED question indefinitely. ## Type enrichment -`GetVariableAccessAttributesSmartAsync` performs structure-first type discovery: +Canonical type enrichment is planned by `LiveIedVariableTypeProbePlanner` and executed by `LiveIedVariableTypeProbeExecutor.ProbeSmartAsync`. -1. Group discovered FC points by MMS `LN$FC$DO` root. -2. Probe each root with GetVariableAccessAttributes using the same bounded association-aware window. -3. When a root returns a structured TypeSpecification, its hierarchy represents descendant attributes. -4. When the root probe fails or does not describe a hierarchy, fall back to exact leaf probes for that root only. +The adaptive ladder is: -This keeps exact metadata available while avoiding one GetVariableAccessAttributes request per leaf on normal IEC 61850 MMS models. +1. Probe each MMS logical-node root (`LN`) once. +2. Use the returned `TypeSpecification` tree to prove every descendant path that is actually present in that hierarchy. +3. Only unresolved branches fall back to distinct `LN$FC$DO` roots. +4. Only descendants still unresolved after the DO-root result fall back to exact leaf GVA. -## Transport safety +This is deliberately coverage-aware: a successful but shallow parent response does not suppress required child probes. Normal structured IEC 61850 servers therefore approach one GVA per logical node, while unusual servers still retain an exact fallback path. -Pipelining requires multiple confirmed requests to be outstanding. `TpktClient` therefore serializes writers around each complete TPKT frame. This is intentionally a write-frame gate only: it prevents byte interleaving without serializing the request/response lifecycle. The existing single receive pump remains the only association reader. +## SCL-assisted scheduling -## Suggested usage +SCL is a scheduling/validation hint, never a substitute for online evidence. `MmsSmartDiscoveryOptions.PriorityDomains` can be populated from the exact expected MMS domains of the trusted SCL IED/AP/Server selection. The engine still performs live VMD `GetNameList`, keeps extra live domains, and publishes the live-selected domain set unchanged. ```csharp -var discovery = await session.DiscoverSmartAsync( - new MmsSmartDiscoveryOptions - { - MaxConcurrentChains = 8, - ProbeReportAttributes = true, - ReadDataSetDirectories = true - }, - cancellationToken); +var options = new MmsSmartDiscoveryOptions +{ + MaxConcurrentChains = 8, + // Optional, when trusted SCL context is already selected: + PriorityDomains = sclDomainInventory?.ExpectedDomains ?? Array.Empty() +}; -var exactTypes = await session.GetVariableAccessAttributesSmartAsync( +var discovery = await session.DiscoverSmartAsync(options, cancellationToken); + +var exactTypes = await LiveIedVariableTypeProbeExecutor.ProbeSmartAsync( + session, discovery.IedDirectory, - cancellationToken: cancellationToken); + options, + cancellationToken); +``` + +Expensive runtime enrichment can be requested explicitly when needed: + +```csharp +var deepOptions = new MmsSmartDiscoveryOptions +{ + MaxConcurrentChains = 8, + ProbeReportAttributes = true, + ReadDataSetDirectories = true +}; ``` The legacy `DiscoverAsync` and `GetVariableAccessAttributesBatchAsync` APIs remain unchanged for compatibility. Consumers can migrate deliberately and compare model completeness before making smart discovery their default. +## Transport safety + +Pipelining requires multiple confirmed requests to be outstanding. `TpktClient` therefore serializes writers around each complete TPKT frame. The frame is allocated inside that single-writer gate, keeping peak outbound-frame allocation bounded while still allowing multiple request/response lifecycles to remain outstanding. The existing single receive pump remains the only association reader. + ## Capture-informed target -The reference capture used during this refactor showed the existing consumer issuing roughly 30.7k confirmed MMS requests, including roughly 23.7k GetVariableAccessAttributes and 6.6k Read requests, while the comparison tool used a much smaller, pipelined request set. These values are benchmark evidence, not protocol requirements. The smart path targets the scheduling pattern—single association, bounded outstanding requests, structural discovery first—without copying vendor-specific behavior. +The reference capture used during this refactor showed the existing consumer issuing roughly 30.7k confirmed MMS requests, including roughly 23.7k GetVariableAccessAttributes and 6.6k Read requests, while the comparison tool used a much smaller, pipelined request set. These values are benchmark evidence, not protocol requirements. The smart path targets the independently observed scheduling pattern—single association, bounded outstanding requests, structural discovery first, hierarchy-aware metadata, selective reads—without copying vendor code or vendor-specific implementation details. For acceptance, compare the same IED and capture conditions using: @@ -60,6 +92,8 @@ For acceptance, compare the same IED and capture conditions using: - confirmed request count by service; - peak outstanding confirmed requests; - failed/partial domain chains; -- exact type coverage after smart enrichment. +- exact type coverage after smart enrichment; +- managed allocations and task count during discovery; +- UI-thread stalls / long frames in the consuming application. A performance result is accepted only when the final model remains semantically equivalent for the required scope. From 38014b21a77d0cd834ecc4df4529924fdcf9723f Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 16 Sep 2026 19:41:59 +0700 Subject: [PATCH 15/66] perf(read): pipeline canonical FC-root initial reads safely --- .../MmsClientSession.SmartInitialFcRead.cs | 313 ++++++++++++++++++ 1 file changed, 313 insertions(+) create mode 100644 src/AR.Iec61850/Mms/MmsClientSession.SmartInitialFcRead.cs diff --git a/src/AR.Iec61850/Mms/MmsClientSession.SmartInitialFcRead.cs b/src/AR.Iec61850/Mms/MmsClientSession.SmartInitialFcRead.cs new file mode 100644 index 00000000..e1f3bf20 --- /dev/null +++ b/src/AR.Iec61850/Mms/MmsClientSession.SmartInitialFcRead.cs @@ -0,0 +1,313 @@ +namespace AR.Iec61850.Mms; + +/// +/// Bounded execution policy for the existing FC-root initial Read plan. This changes +/// only scheduling; target selection stays in the canonical/SCL/live planners. +/// +public sealed class MmsSmartInitialFcReadOptions +{ + /// + /// Maximum number of independent batch Reads that may be outstanding together. + /// The effective value is additionally capped by negotiated maxOutstandingCalling. + /// + public int MaxOutstandingBatches { get; init; } = 4; + + /// + /// Conservative cap when the association limit could not be decoded. + /// + public int UnknownPeerMaxOutstandingBatches { get; init; } = 2; + + /// + /// Optional explicit deadline for one confirmed Read batch. Non-positive values + /// reuse the session connection timeout. + /// + public TimeSpan PerBatchTimeout { get; init; } +} + +public sealed partial class MmsClientSession +{ + /// + /// Executes the existing bounded FC-root Read plan with a fixed-size worker pool. + /// Each batch remains one ordered MMS Confirmed-Read, responses remain invoke-ID + /// correlated, and final batch publication is deterministic by plan index. + /// + /// A timeout or transport fault closes the association exactly once because a + /// confirmed request may already be on the wire. Other workers then fail soft and + /// the valid completed batches are preserved in the result. + /// + public async Task ExecuteInitialFcReadPlanSmartAsync( + InitialFcReadPlan plan, + MmsSmartInitialFcReadOptions? options = null, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(plan); + options ??= new MmsSmartInitialFcReadOptions(); + + var effectiveTimeout = options.PerBatchTimeout > TimeSpan.Zero + ? options.PerBatchTimeout + : _lastTimeout > TimeSpan.Zero + ? _lastTimeout + : TimeSpan.FromSeconds(5); + + var validationError = ValidateSmartInitialFcReadPlan(plan); + if (!string.IsNullOrWhiteSpace(validationError)) + { + return new InitialFcReadExecutionResult + { + Status = InitialFcReadExecutionStatus.InvalidPlan, + Plan = plan, + PerBatchTimeout = effectiveTimeout, + Message = validationError + }; + } + + if (!IsMmsInitiated || !IsTransportConnected) + { + return new InitialFcReadExecutionResult + { + Status = InitialFcReadExecutionStatus.SessionNotReady, + Plan = plan, + PerBatchTimeout = effectiveTimeout, + Message = $"Smart initial FC-root Read requires an initiated MMS association; current state={State}." + }; + } + + var orderedBatches = plan.Batches + .OrderBy(batch => batch.Index) + .ToArray(); + var executions = new InitialFcReadBatchExecution?[orderedBatches.Length]; + var outcomes = new SmartInitialReadBatchOutcome[orderedBatches.Length]; + var nextIndex = -1; + var resetStarted = 0; + var requestedWindow = Math.Clamp(options.MaxOutstandingBatches, 1, 16); + var unknownWindow = Math.Clamp(options.UnknownPeerMaxOutstandingBatches, 1, 8); + var window = MmsSmartDiscoveryPolicy.ResolveWindow( + requestedWindow, + unknownWindow, + LastNegotiatedCapabilities.MaxOutstandingCalling); + var workerCount = Math.Min(window, orderedBatches.Length); + var workers = new Task[workerCount]; + + for (var worker = 0; worker < workerCount; worker++) + workers[worker] = WorkerAsync(); + + try + { + await Task.WhenAll(workers).ConfigureAwait(false); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + await ResetAssociationOnceAsync().ConfigureAwait(false); + throw; + } + + var materialized = new List(orderedBatches.Length); + for (var index = 0; index < orderedBatches.Length; index++) + { + if (executions[index] is not null) + { + materialized.Add(executions[index]!); + continue; + } + + var batch = orderedBatches[index]; + var message = "Batch was not started because the MMS association became unavailable."; + materialized.Add(new InitialFcReadBatchExecution + { + BatchIndex = batch.Index, + Targets = batch.Targets, + Read = BuildFailedInitialFcBatch(batch.References, message) + }); + outcomes[index] = SmartInitialReadBatchOutcome.Skipped; + } + + var failedTargets = materialized.Sum(batch => batch.Read.Results.Count(result => !result.IsSuccess)); + var projectionErrors = materialized.Sum(batch => batch.Projections.Sum(projection => projection.Errors.Count)); + var timedOut = outcomes.Any(outcome => outcome == SmartInitialReadBatchOutcome.TimedOut); + var transportFailure = outcomes.Any(outcome => outcome == SmartInitialReadBatchOutcome.TransportFailure); + var skipped = outcomes.Count(outcome => outcome == SmartInitialReadBatchOutcome.Skipped); + var status = timedOut + ? InitialFcReadExecutionStatus.TimedOut + : transportFailure + ? InitialFcReadExecutionStatus.TransportFailure + : failedTargets == 0 && projectionErrors == 0 && skipped == 0 + ? InitialFcReadExecutionStatus.Completed + : InitialFcReadExecutionStatus.Partial; + + return new InitialFcReadExecutionResult + { + Status = status, + Plan = plan, + PerBatchTimeout = effectiveTimeout, + Batches = materialized, + Message = + $"Smart initial FC-root Read: batches={materialized.Count}/{orderedBatches.Length}, " + + $"window={window}, negotiatedCalling={LastNegotiatedCapabilities.MaxOutstandingCalling?.ToString() ?? "unknown"}, " + + $"successfulTargets={materialized.Sum(batch => batch.Read.Results.Count(result => result.IsSuccess))}, " + + $"failedTargets={failedTargets}, projectedLeaves={materialized.Sum(batch => batch.Projections.Sum(projection => projection.Leaves.Count))}, " + + $"projectionErrors={projectionErrors}, skippedBatches={skipped}." + }; + + async Task WorkerAsync() + { + while (true) + { + cancellationToken.ThrowIfCancellationRequested(); + if (!IsMmsInitiated || Volatile.Read(ref resetStarted) != 0) + return; + + var index = Interlocked.Increment(ref nextIndex); + if (index >= orderedBatches.Length) + return; + + var batch = orderedBatches[index]; + var execution = await ExecuteSmartInitialReadBatchAsync( + batch, + effectiveTimeout, + cancellationToken) + .ConfigureAwait(false); + executions[index] = execution.Execution; + outcomes[index] = execution.Outcome; + + if (execution.RequiresAssociationReset) + { + await ResetAssociationOnceAsync().ConfigureAwait(false); + return; + } + } + } + + async Task ResetAssociationOnceAsync() + { + if (Interlocked.CompareExchange(ref resetStarted, 1, 0) != 0) + return; + + await MarkProtocolFaultAsync().ConfigureAwait(false); + } + } + + private async Task ExecuteSmartInitialReadBatchAsync( + InitialFcReadBatch batch, + TimeSpan timeout, + CancellationToken cancellationToken) + { + var references = batch.References; + var invokeId = NextInvokeId(); + var request = MmsReadBatchCodec.BuildRequest( + invokeId, + references, + MmsReadPayloadProfile.PresentationDataValues); + + using var deadline = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + deadline.CancelAfter(timeout); + + MmsReadBatchResult read; + try + { + var response = await SendConfirmedPresentationPayloadAsync( + request, + invokeId, + deadline.Token) + .ConfigureAwait(false); + read = MmsReadBatchCodec.DecodeResponse(response, references, invokeId); + } + catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested && deadline.IsCancellationRequested) + { + var message = $"Smart initial FC-root Read batch {batch.Index} timed out after {timeout.TotalMilliseconds:0} ms."; + return new SmartInitialReadBatchResult( + BuildSmartInitialReadExecution(batch, BuildFailedInitialFcBatch(references, message)), + SmartInitialReadBatchOutcome.TimedOut, + RequiresAssociationReset: true); + } + catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) + { + var message = $"Smart initial FC-root Read batch {batch.Index} stopped because the receive pump/association closed."; + return new SmartInitialReadBatchResult( + BuildSmartInitialReadExecution(batch, BuildFailedInitialFcBatch(references, message)), + SmartInitialReadBatchOutcome.Skipped, + RequiresAssociationReset: false); + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) when (ex is IOException or InvalidDataException or ObjectDisposedException or InvalidOperationException) + { + var message = $"Smart initial FC-root Read batch {batch.Index} transport/session failure: {ex.GetType().Name}: {ex.Message}"; + return new SmartInitialReadBatchResult( + BuildSmartInitialReadExecution(batch, BuildFailedInitialFcBatch(references, message)), + SmartInitialReadBatchOutcome.TransportFailure, + RequiresAssociationReset: true); + } + + return new SmartInitialReadBatchResult( + BuildSmartInitialReadExecution(batch, read), + read.IsSuccess + ? SmartInitialReadBatchOutcome.Completed + : SmartInitialReadBatchOutcome.Partial, + RequiresAssociationReset: false); + } + + private static InitialFcReadBatchExecution BuildSmartInitialReadExecution( + InitialFcReadBatch batch, + MmsReadBatchResult read) + { + var projections = new List(); + for (var index = 0; index < batch.Targets.Count && index < read.Results.Count; index++) + { + var access = read.Results[index]; + var target = batch.Targets[index]; + if (!access.IsSuccess || access.Value is null || target.DataObjects.Count == 0) + continue; + + projections.Add(InitialFcValueProjector.Project(target, access.Value)); + } + + return new InitialFcReadBatchExecution + { + BatchIndex = batch.Index, + Targets = batch.Targets, + Read = read, + Projections = projections + }; + } + + private static string ValidateSmartInitialFcReadPlan(InitialFcReadPlan plan) + { + if (!plan.IsValid) + return "Initial FC-root Read plan is invalid: " + string.Join(" | ", plan.Errors); + if (plan.MaximumVariableReferencesPerRead < 1 || + plan.MaximumVariableReferencesPerRead > MmsReadBatchCodec.MaximumVariableReferencesPerRead) + { + return $"Initial FC-root Read plan exceeds the bounded per-request limit of {MmsReadBatchCodec.MaximumVariableReferencesPerRead}."; + } + + foreach (var batch in plan.Batches) + { + if (batch.Targets.Count == 0) + return $"Initial FC-root Read batch {batch.Index} is empty."; + if (batch.Targets.Count > plan.MaximumVariableReferencesPerRead || + batch.Targets.Count > MmsReadBatchCodec.MaximumVariableReferencesPerRead) + { + return $"Initial FC-root Read batch {batch.Index} contains {batch.Targets.Count} targets and exceeds the bounded request size."; + } + } + + return string.Empty; + } + + private enum SmartInitialReadBatchOutcome + { + None, + Completed, + Partial, + TimedOut, + TransportFailure, + Skipped + } + + private sealed record SmartInitialReadBatchResult( + InitialFcReadBatchExecution Execution, + SmartInitialReadBatchOutcome Outcome, + bool RequiresAssociationReset); +} From 481b9808502fcc750dd82b1cbe5f2eb78f2e54fd Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 16 Sep 2026 19:44:08 +0700 Subject: [PATCH 16/66] test(read): lock bounded FC-root smart read policy --- .../Mms/MmsSmartInitialFcReadPolicyTests.cs | 46 +++++++++++++++++++ 1 file changed, 46 insertions(+) create mode 100644 tests/AR.Iec61850.Tests/Mms/MmsSmartInitialFcReadPolicyTests.cs diff --git a/tests/AR.Iec61850.Tests/Mms/MmsSmartInitialFcReadPolicyTests.cs b/tests/AR.Iec61850.Tests/Mms/MmsSmartInitialFcReadPolicyTests.cs new file mode 100644 index 00000000..88a870d3 --- /dev/null +++ b/tests/AR.Iec61850.Tests/Mms/MmsSmartInitialFcReadPolicyTests.cs @@ -0,0 +1,46 @@ +using AR.Iec61850.Mms; + +namespace AR.Iec61850.Tests.Mms; + +public sealed class MmsSmartInitialFcReadPolicyTests +{ + [Fact] + public void Defaults_UseSmallBoundedReadWindow() + { + var options = new MmsSmartInitialFcReadOptions(); + + Assert.Equal(4, options.MaxOutstandingBatches); + Assert.Equal(2, options.UnknownPeerMaxOutstandingBatches); + Assert.Equal(TimeSpan.Zero, options.PerBatchTimeout); + } + + [Fact] + public void FcRootPlanner_RemainsBoundedAndDoesNotExpandToLeafReads() + { + var directory = MmsIedModelDirectoryBuilder.Build(new MmsDiscoverySnapshot + { + DomainVariables = new Dictionary>(StringComparer.OrdinalIgnoreCase) + { + ["LD0"] = + [ + "XCBR1$ST$Pos$stVal", + "XCBR1$ST$Pos$q", + "XCBR1$CF$Pos$ctlModel", + "MMXU1$MX$PhV$phsA$cVal$mag$f", + "MMXU1$MX$PhV$phsB$cVal$mag$f" + ] + } + }); + + var plan = InitialFcReadPlanner.FromLiveDirectory(directory); + var references = plan.Batches.SelectMany(batch => batch.References).ToArray(); + + Assert.Equal(3, references.Length); + Assert.Contains(references, reference => reference.Domain == "LD0" && reference.Item == "XCBR1$ST"); + Assert.Contains(references, reference => reference.Domain == "LD0" && reference.Item == "XCBR1$CF"); + Assert.Contains(references, reference => reference.Domain == "LD0" && reference.Item == "MMXU1$MX"); + Assert.DoesNotContain(references, reference => reference.Item.Contains("$Pos$", StringComparison.Ordinal)); + Assert.DoesNotContain(references, reference => reference.Item.Contains("$PhV$", StringComparison.Ordinal)); + Assert.All(plan.Batches, batch => Assert.InRange(batch.References.Count, 1, MmsReadBatchCodec.MaximumVariableReferencesPerRead)); + } +} From 68d35a5fcf4d5f501b7d70cff75b004efdf9695e Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 16 Sep 2026 19:45:36 +0700 Subject: [PATCH 17/66] perf(model): index type hierarchy mapping by logical node --- .../Discovery/LiveIedVariableTypeHierarchy.cs | 56 ++++++++++++++----- 1 file changed, 43 insertions(+), 13 deletions(-) diff --git a/src/AR.Iec61850/Discovery/LiveIedVariableTypeHierarchy.cs b/src/AR.Iec61850/Discovery/LiveIedVariableTypeHierarchy.cs index 021f2709..64ba9cca 100644 --- a/src/AR.Iec61850/Discovery/LiveIedVariableTypeHierarchy.cs +++ b/src/AR.Iec61850/Discovery/LiveIedVariableTypeHierarchy.cs @@ -50,6 +50,8 @@ public static Task> ProbeSmartA internal sealed class LiveIedVariableTypeHierarchyIndex { + private const char CompositeKeySeparator = '\u001F'; + private readonly Dictionary _byMmsReference = new(StringComparer.OrdinalIgnoreCase); @@ -67,8 +69,34 @@ public static LiveIedVariableTypeHierarchyIndex Build( ArgumentNullException.ThrowIfNull(results); var index = new LiveIedVariableTypeHierarchyIndex(); + if (directory.PointCount == 0 || results.Count == 0) + return index; + + // Build one cheap LN-local candidate index. The previous implementation scanned + // every point in the entire IED for every successful GVA result, which made CPU + // mapping approach O(points * typeResults) on large models. Every MMS variable + // path begins at one logical node, so results can be constrained to that LN. + var pointsByLogicalNode = directory.Points + .GroupBy( + point => BuildLogicalNodeKey(point.Domain, point.LogicalNode), + StringComparer.OrdinalIgnoreCase) + .ToDictionary( + group => group.Key, + group => (IReadOnlyList)group.ToArray(), + StringComparer.OrdinalIgnoreCase); + foreach (var result in results.Where(result => result.IsSuccess && result.TypeSpecification is not null)) - index.AddResult(directory, result); + { + var rootParts = SplitMmsItem(result.Reference.Item); + if (rootParts.Length == 0 || string.IsNullOrWhiteSpace(result.Reference.Domain)) + continue; + + var logicalNodeKey = BuildLogicalNodeKey(result.Reference.Domain, rootParts[0]); + if (!pointsByLogicalNode.TryGetValue(logicalNodeKey, out var candidates)) + continue; + + index.AddResult(candidates, result, rootParts); + } return index; } @@ -76,24 +104,21 @@ public static LiveIedVariableTypeHierarchyIndex Build( public bool TryResolve(MmsFcResolvedPoint point, out LiveIedVariableTypeResolution resolution) => _byMmsReference.TryGetValue(point.MmsReference, out resolution!); - private void AddResult(MmsIedModelDirectory directory, MmsVariableAccessAttributesResult result) + private void AddResult( + IReadOnlyList candidatePoints, + MmsVariableAccessAttributesResult result, + IReadOnlyList rootParts) { - var rootItem = result.Reference.Item.Trim(); - if (string.IsNullOrWhiteSpace(result.Reference.Domain) || string.IsNullOrWhiteSpace(rootItem) || result.TypeSpecification is null) + if (result.TypeSpecification is null) return; - var rootParts = SplitMmsItem(rootItem); - if (rootParts.Length == 0) - return; - - foreach (var point in directory.Points.Where(point => - string.Equals(point.Domain, result.Reference.Domain, StringComparison.OrdinalIgnoreCase))) + foreach (var point in candidatePoints) { var pointParts = SplitMmsItem(point.MmsItemName); if (!HasPrefix(pointParts, rootParts)) continue; - var remainder = pointParts[rootParts.Length..]; + var remainder = pointParts[rootParts.Count..]; var type = ResolvePath(result.TypeSpecification, remainder); if (type is null) continue; @@ -105,7 +130,7 @@ private void AddResult(MmsIedModelDirectory directory, MmsVariableAccessAttribut type, source, $"Mapped from {result.ReferenceKey} type hierarchy. {result.Message}", - rootParts.Length); + rootParts.Count); if (!_byMmsReference.TryGetValue(point.MmsReference, out var existing) || resolution.Specificity > existing.Specificity) @@ -131,8 +156,13 @@ private void AddResult(MmsIedModelDirectory directory, MmsVariableAccessAttribut return current; } + private static string BuildLogicalNodeKey(string domain, string logicalNode) + => string.Concat(domain ?? string.Empty, CompositeKeySeparator, logicalNode ?? string.Empty); + private static string[] SplitMmsItem(string value) - => value.Split('$', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + => (value ?? string.Empty).Split( + '$', + StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); private static bool HasPrefix(IReadOnlyList value, IReadOnlyList prefix) { From 85f7e895cc5c62c60404077e839d740d9e6021cc Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 16 Sep 2026 19:46:19 +0700 Subject: [PATCH 18/66] docs(discovery): add smart FC-root read execution --- docs/SMART_MMS_DISCOVERY.md | 40 ++++++++++++++++++++++++++++++++++--- 1 file changed, 37 insertions(+), 3 deletions(-) diff --git a/docs/SMART_MMS_DISCOVERY.md b/docs/SMART_MMS_DISCOVERY.md index 2b188cdf..a1ed044e 100644 --- a/docs/SMART_MMS_DISCOVERY.md +++ b/docs/SMART_MMS_DISCOVERY.md @@ -6,7 +6,7 @@ - One TCP/COTP/MMS association and one receive pump per session. - Confirmed responses are correlated by invoke ID; discovery never starts a second raw receive loop. -- The VMD domain list is enumerated once per discovery operation. +- The VMD domain list is enumerated once per smart discovery operation. - Independent `(domain, object-class)` GetNameList chains may be outstanding concurrently. - Continuation pages inside one GetNameList chain are always sequential. - The effective discovery window is capped by the peer's negotiated `maxOutstandingCalling` when available. Unknown peers use a conservative fallback cap. @@ -42,6 +42,39 @@ The adaptive ladder is: This is deliberately coverage-aware: a successful but shallow parent response does not suppress required child probes. Normal structured IEC 61850 servers therefore approach one GVA per logical node, while unusual servers still retain an exact fallback path. +Type-tree projection into the canonical live model is indexed by MMS Domain/LogicalNode before results are mapped. A type result therefore scans only the relevant LN instead of repeatedly scanning every point in the IED. + +## Initial value reads + +Initial values use the existing canonical/SCL/live `InitialFcReadPlan`; the smart path does not invent a second read model. The planner groups leaf points into FC roots such as `XCBR1$ST` or `MMXU1$MX` and batches up to the bounded MMS request limit. + +`ExecuteInitialFcReadPlanSmartAsync` changes only the scheduling layer: + +- default maximum four outstanding Read batches when association limits are known; +- conservative two-batch window when `maxOutstandingCalling` is unknown; +- always capped by the negotiated association limit; +- fixed worker count instead of Task-per-batch; +- invoke-ID response correlation remains authoritative; +- per-batch timeout remains explicit; +- one timeout/transport fault resets the association once so late responses cannot become stale evidence; +- completed batches and projections are preserved when another batch fails; +- no automatic retry/split storm and no fallback to thousands of leaf Reads. + +Example: + +```csharp +var initialPlan = InitialFcReadPlanner.FromLiveDirectory(discovery.IedDirectory); +var initialValues = await session.ExecuteInitialFcReadPlanSmartAsync( + initialPlan, + new MmsSmartInitialFcReadOptions + { + MaxOutstandingBatches = 4 + }, + cancellationToken); +``` + +For an opened SCL model, use the canonical SCL initial-read planner already provided by the engine and execute that same plan through the smart executor. + ## SCL-assisted scheduling SCL is a scheduling/validation hint, never a substitute for online evidence. `MmsSmartDiscoveryOptions.PriorityDomains` can be populated from the exact expected MMS domains of the trusted SCL IED/AP/Server selection. The engine still performs live VMD `GetNameList`, keeps extra live domains, and publishes the live-selected domain set unchanged. @@ -74,7 +107,7 @@ var deepOptions = new MmsSmartDiscoveryOptions }; ``` -The legacy `DiscoverAsync` and `GetVariableAccessAttributesBatchAsync` APIs remain unchanged for compatibility. Consumers can migrate deliberately and compare model completeness before making smart discovery their default. +The legacy `DiscoverAsync`, `GetVariableAccessAttributesBatchAsync`, and sequential `ExecuteInitialFcReadPlanAsync` APIs remain unchanged for compatibility. Consumers can migrate deliberately and compare model completeness before making smart paths their default. ## Transport safety @@ -82,7 +115,7 @@ Pipelining requires multiple confirmed requests to be outstanding. `TpktClient` ## Capture-informed target -The reference capture used during this refactor showed the existing consumer issuing roughly 30.7k confirmed MMS requests, including roughly 23.7k GetVariableAccessAttributes and 6.6k Read requests, while the comparison tool used a much smaller, pipelined request set. These values are benchmark evidence, not protocol requirements. The smart path targets the independently observed scheduling pattern—single association, bounded outstanding requests, structural discovery first, hierarchy-aware metadata, selective reads—without copying vendor code or vendor-specific implementation details. +The reference capture used during this refactor showed the existing consumer issuing roughly 30.7k confirmed MMS requests, including roughly 23.7k GetVariableAccessAttributes and 6.6k Read requests, while the comparison tool used a much smaller, pipelined request set. These values are benchmark evidence, not protocol requirements. The smart path targets the independently observed scheduling pattern—single association, bounded outstanding requests, structural discovery first, hierarchy-aware metadata, FC-root selective reads—without copying vendor code or vendor-specific implementation details. For acceptance, compare the same IED and capture conditions using: @@ -93,6 +126,7 @@ For acceptance, compare the same IED and capture conditions using: - peak outstanding confirmed requests; - failed/partial domain chains; - exact type coverage after smart enrichment; +- FC-root initial-value coverage and Read request count; - managed allocations and task count during discovery; - UI-thread stalls / long frames in the consuming application. From 779a8e875b7f438f6cc9d4c2989a5b619082a9c2 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 16 Sep 2026 19:48:10 +0700 Subject: [PATCH 19/66] fix(discovery): harden smart reference comparer against default values --- .../Mms/MmsClientSession.SmartVariableAccessAttributes.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/AR.Iec61850/Mms/MmsClientSession.SmartVariableAccessAttributes.cs b/src/AR.Iec61850/Mms/MmsClientSession.SmartVariableAccessAttributes.cs index 7d5c2be7..f88bfeeb 100644 --- a/src/AR.Iec61850/Mms/MmsClientSession.SmartVariableAccessAttributes.cs +++ b/src/AR.Iec61850/Mms/MmsClientSession.SmartVariableAccessAttributes.cs @@ -236,8 +236,8 @@ private sealed class MmsObjectReferenceKeyComparer : IEqualityComparer x.Domain.Equals(y.Domain, StringComparison.OrdinalIgnoreCase) && - x.Item.Equals(y.Item, StringComparison.OrdinalIgnoreCase); + => string.Equals(x.Domain, y.Domain, StringComparison.OrdinalIgnoreCase) && + string.Equals(x.Item, y.Item, StringComparison.OrdinalIgnoreCase); public int GetHashCode(MmsObjectReference obj) => HashCode.Combine( From 57bcea0236e2e583f17fd29dd322ceea577d53ba Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 16 Sep 2026 19:48:38 +0700 Subject: [PATCH 20/66] docs(discovery): define consumer anti-lag integration rule --- docs/SMART_MMS_DISCOVERY.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/SMART_MMS_DISCOVERY.md b/docs/SMART_MMS_DISCOVERY.md index a1ed044e..1fbe3ca8 100644 --- a/docs/SMART_MMS_DISCOVERY.md +++ b/docs/SMART_MMS_DISCOVERY.md @@ -113,6 +113,10 @@ The legacy `DiscoverAsync`, `GetVariableAccessAttributesBatchAsync`, and sequent Pipelining requires multiple confirmed requests to be outstanding. `TpktClient` therefore serializes writers around each complete TPKT frame. The frame is allocated inside that single-writer gate, keeping peak outbound-frame allocation bounded while still allowing multiple request/response lifecycles to remain outstanding. The existing single receive pump remains the only association reader. +## Consumer anti-lag rule + +The engine smart methods use asynchronous I/O and `ConfigureAwait(false)` internally, so MMS waits, response decoding, and canonical model materialization do not require the caller's UI synchronization context. A UI consumer should publish immutable snapshots/results back to the UI in coarse batches rather than adding thousands of tree nodes one by one. Discovery/type/value phases are intentionally separate so the first structural model can be shown before optional metadata/value enrichment finishes. + ## Capture-informed target The reference capture used during this refactor showed the existing consumer issuing roughly 30.7k confirmed MMS requests, including roughly 23.7k GetVariableAccessAttributes and 6.6k Read requests, while the comparison tool used a much smaller, pipelined request set. These values are benchmark evidence, not protocol requirements. The smart path targets the independently observed scheduling pattern—single association, bounded outstanding requests, structural discovery first, hierarchy-aware metadata, FC-root selective reads—without copying vendor code or vendor-specific implementation details. From 56d323e7fc06aacf22e450a4d4f3a8d76898aab8 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 16 Sep 2026 19:49:28 +0700 Subject: [PATCH 21/66] test(model): verify LN-scoped type hierarchy mapping --- .../Mms/MmsSmartDiscoveryPolicyTests.cs | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/tests/AR.Iec61850.Tests/Mms/MmsSmartDiscoveryPolicyTests.cs b/tests/AR.Iec61850.Tests/Mms/MmsSmartDiscoveryPolicyTests.cs index 437dc7d4..438b50e9 100644 --- a/tests/AR.Iec61850.Tests/Mms/MmsSmartDiscoveryPolicyTests.cs +++ b/tests/AR.Iec61850.Tests/Mms/MmsSmartDiscoveryPolicyTests.cs @@ -94,6 +94,30 @@ public void TypeCoverage_UsesParentHierarchyAndRejectsUnknownBranch() "XCBR1$ST$Pos$stVal")); } + [Fact] + public void TypeHierarchyIndex_MapsLogicalNodeTreeWithoutCrossNodeLeakage() + { + var directory = BuildDirectory(); + var typeResult = new MmsVariableAccessAttributesResult + { + IsSuccess = true, + Reference = new MmsObjectReference("LD0", "XCBR1", string.Empty), + TypeSpecification = Node( + "", + Node("ST", + Node("Pos", + Node("stVal"), + Node("q")))) + }; + + var index = LiveIedVariableTypeHierarchyIndex.Build(directory, [typeResult]); + var xcbrPoint = directory.Points.Single(point => point.MmsItemName == "XCBR1$ST$Pos$stVal"); + var mmxuPoint = directory.Points.Single(point => point.MmsItemName == "MMXU1$MX$PhV$phsA$cVal$mag$f"); + + Assert.True(index.TryResolve(xcbrPoint, out _)); + Assert.False(index.TryResolve(mmxuPoint, out _)); + } + [Fact] public void DataObjectFallbackRoot_StopsAtLnFcDo() { From 2ba1ba66b9b022d6d1c31447cdd9d11b2453dd48 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 16 Sep 2026 19:54:38 +0700 Subject: [PATCH 22/66] ci: publish PR test build artifacts --- .github/workflows/dotnet-ci.yml | 41 +++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/.github/workflows/dotnet-ci.yml b/.github/workflows/dotnet-ci.yml index 17630122..80f7ddc8 100644 --- a/.github/workflows/dotnet-ci.yml +++ b/.github/workflows/dotnet-ci.yml @@ -66,6 +66,47 @@ jobs: Tee-Object -FilePath artifacts/test-results/dotnet-test.log if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + - name: Package test build + if: success() + shell: pwsh + run: | + $root = "artifacts/ariec61850-test-build" + $binary = Join-Path $root "lib/net8.0" + $nuget = Join-Path $root "nuget" + New-Item -ItemType Directory -Path $binary -Force | Out-Null + New-Item -ItemType Directory -Path $nuget -Force | Out-Null + + Copy-Item ".\source\src\AR.Iec61850\bin\Release\net8.0\*" $binary -Recurse -Force + + $packageVersion = "0.0.0-pr134.$env:GITHUB_RUN_NUMBER" + dotnet pack .\source\src\AR.Iec61850\AR.Iec61850.csproj ` + -c Release ` + --no-build ` + -o $nuget ` + -p:PackageVersion=$packageVersion + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + + @( + "ARIEC61850 PR #134 smart-discovery test build", + "Commit: $env:GITHUB_SHA", + "Branch: $env:GITHUB_HEAD_REF", + "Run: $env:GITHUB_RUN_NUMBER", + "TargetFramework: net8.0", + "NuGetVersion: $packageVersion", + "", + "lib/net8.0 contains the direct Release build for DLL-reference testing.", + "nuget contains the installable local .nupkg for PackageReference testing." + ) | Set-Content -Path (Join-Path $root "BUILD.txt") -Encoding utf8 + + - name: Upload PR test build + if: success() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: ariec61850-pr134-test-build + path: artifacts/ariec61850-test-build + if-no-files-found: error + retention-days: 14 + - name: Upload test diagnostics if: always() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 From 040718027b92681b89f2e04ce048a53fe225a1c7 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 16 Sep 2026 19:57:38 +0700 Subject: [PATCH 23/66] fix(ci): package test build from centralized artifacts --- .github/workflows/dotnet-ci.yml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.github/workflows/dotnet-ci.yml b/.github/workflows/dotnet-ci.yml index 80f7ddc8..dbd7b027 100644 --- a/.github/workflows/dotnet-ci.yml +++ b/.github/workflows/dotnet-ci.yml @@ -73,10 +73,14 @@ jobs: $root = "artifacts/ariec61850-test-build" $binary = Join-Path $root "lib/net8.0" $nuget = Join-Path $root "nuget" + $builtBinary = ".\source\.artifacts\bin\AR.Iec61850\Release\net8.0" New-Item -ItemType Directory -Path $binary -Force | Out-Null New-Item -ItemType Directory -Path $nuget -Force | Out-Null - Copy-Item ".\source\src\AR.Iec61850\bin\Release\net8.0\*" $binary -Recurse -Force + if (-not (Test-Path $builtBinary)) { + throw "Expected Release output was not found at $builtBinary" + } + Copy-Item (Join-Path $builtBinary "*") $binary -Recurse -Force $packageVersion = "0.0.0-pr134.$env:GITHUB_RUN_NUMBER" dotnet pack .\source\src\AR.Iec61850\AR.Iec61850.csproj ` From c399ff9c24e8bed04f3f2a1fda21cf08ca9f3588 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 16 Sep 2026 23:40:58 +0700 Subject: [PATCH 24/66] fix(discovery): materialize LN type hierarchy descendants Preserve named semantic leaves proven by logical-node GetVariableAccessAttributes trees in the canonical live directory without adding per-leaf MMS requests. Existing observed GetNameList points remain authoritative; unknown FCs, arrays, and anonymous placeholders are skipped. --- .../Discovery/LiveIedVariableTypeHierarchy.cs | 119 ++++++++++++++- src/AR.Iec61850/Mms/MmsIedModelDirectory.cs | 90 +++++++---- ...eIedTypeHierarchyPointMaterializerTests.cs | 142 ++++++++++++++++++ 3 files changed, 314 insertions(+), 37 deletions(-) create mode 100644 tests/AR.Iec61850.Tests/Mms/LiveIedTypeHierarchyPointMaterializerTests.cs diff --git a/src/AR.Iec61850/Discovery/LiveIedVariableTypeHierarchy.cs b/src/AR.Iec61850/Discovery/LiveIedVariableTypeHierarchy.cs index 64ba9cca..60460fff 100644 --- a/src/AR.Iec61850/Discovery/LiveIedVariableTypeHierarchy.cs +++ b/src/AR.Iec61850/Discovery/LiveIedVariableTypeHierarchy.cs @@ -48,6 +48,115 @@ public static Task> ProbeSmartA } } +/// +/// Materializes named IEC 61850 leaf paths that are present in an authoritative +/// logical-node TypeSpecification but absent from the flat GetNameList inventory. +/// Existing live GetNameList/DataSet points always win; this only fills proven gaps. +/// +internal static class LiveIedTypeHierarchyPointMaterializer +{ + private static readonly HashSet FunctionalConstraints = new( + [ + "ST", "MX", "SP", "SV", "CF", "DC", "SG", "SE", "SR", "OR", + "BL", "EX", "CO", "RP", "BR", "LG", "GO", "GS", "MS", "US" + ], + StringComparer.OrdinalIgnoreCase); + + public static int Augment( + MmsIedModelDirectory directory, + IReadOnlyList results) + { + ArgumentNullException.ThrowIfNull(directory); + ArgumentNullException.ThrowIfNull(results); + + if (directory.PointCount == 0 || results.Count == 0) + return 0; + + var supplemental = new List(); + + foreach (var result in results.Where(result => + result.IsSuccess && + result.TypeSpecification is not null && + !string.IsNullOrWhiteSpace(result.Reference.Domain) && + !string.IsNullOrWhiteSpace(result.Reference.Item) && + !result.Reference.Item.Contains('$'))) + { + var domain = result.Reference.Domain.Trim(); + var logicalNode = result.Reference.Item.Trim(); + if (!directory.LogicalDevices.TryGetValue(domain, out var logicalDevice) || + !logicalDevice.LogicalNodes.ContainsKey(logicalNode)) + { + continue; + } + + foreach (var fcNode in result.TypeSpecification!.Children) + { + var functionalConstraint = NormalizeFunctionalConstraint(fcNode.Name); + if (string.IsNullOrWhiteSpace(functionalConstraint)) + continue; + + foreach (var child in fcNode.Children) + Visit(child, functionalConstraint, []); + } + + void Visit( + MmsTypeSpecificationNode node, + string functionalConstraint, + IReadOnlyList parentPath) + { + var name = (node.Name ?? string.Empty).Trim(); + if (!IsUsableComponentName(name) || + string.Equals(node.MmsType, "array", StringComparison.OrdinalIgnoreCase)) + { + return; + } + + var path = new string[parentPath.Count + 1]; + for (var index = 0; index < parentPath.Count; index++) + path[index] = parentPath[index]; + path[^1] = name; + + if (node.Children.Count > 0) + { + foreach (var child in node.Children) + Visit(child, functionalConstraint, path); + return; + } + + // An IEC 61850 leaf below an FC must contain at least DO + DA. + if (path.Length < 2) + return; + + supplemental.Add(new MmsFcResolvedPoint + { + Domain = domain, + LogicalNode = logicalNode, + FunctionalConstraint = functionalConstraint, + DataObjectPath = string.Join(".", path), + MmsItemName = $"{logicalNode}${functionalConstraint}${string.Join("$", path)}", + Source = "GetVariableAccessAttributesLogicalNodeTree", + Confidence = 100 + }); + } + } + + return directory.AddSupplementalPoints(supplemental); + } + + private static string NormalizeFunctionalConstraint(string value) + { + var normalized = (value ?? string.Empty).Trim().ToUpperInvariant(); + return FunctionalConstraints.Contains(normalized) ? normalized : string.Empty; + } + + private static bool IsUsableComponentName(string value) + => !string.IsNullOrWhiteSpace(value) && + !string.Equals(value, "element", StringComparison.OrdinalIgnoreCase) && + value[0] != '[' && + !value.Contains('$') && + !value.Contains('.'); +} + internal sealed class LiveIedVariableTypeHierarchyIndex { private const char CompositeKeySeparator = '\u001F'; @@ -72,10 +181,12 @@ public static LiveIedVariableTypeHierarchyIndex Build( if (directory.PointCount == 0 || results.Count == 0) return index; - // Build one cheap LN-local candidate index. The previous implementation scanned - // every point in the entire IED for every successful GVA result, which made CPU - // mapping approach O(points * typeResults) on large models. Every MMS variable - // path begins at one logical node, so results can be constrained to that LN. + // The live LN-root GVA tree can contain semantic descendants that are not + // individually enumerated by GetNameList. Materialize those proven leaves + // before indexing so the canonical model and SCL builder can see them without + // issuing per-leaf MMS requests. + LiveIedTypeHierarchyPointMaterializer.Augment(directory, results); + var pointsByLogicalNode = directory.Points .GroupBy( point => BuildLogicalNodeKey(point.Domain, point.LogicalNode), diff --git a/src/AR.Iec61850/Mms/MmsIedModelDirectory.cs b/src/AR.Iec61850/Mms/MmsIedModelDirectory.cs index e8855add..9a6c11f5 100644 --- a/src/AR.Iec61850/Mms/MmsIedModelDirectory.cs +++ b/src/AR.Iec61850/Mms/MmsIedModelDirectory.cs @@ -2,45 +2,17 @@ namespace AR.Iec61850.Mms; public sealed class MmsIedModelDirectory { - private readonly Dictionary _logicalDevices; - private readonly Dictionary> _pointsByUserReference; - private readonly Dictionary _pointsByMmsReference; + private readonly Dictionary _logicalDevices = new(StringComparer.OrdinalIgnoreCase); + private readonly Dictionary> _pointsByUserReference = new(StringComparer.OrdinalIgnoreCase); + private readonly Dictionary _pointsByMmsReference = new(StringComparer.OrdinalIgnoreCase); public MmsIedModelDirectory(IEnumerable points) { ArgumentNullException.ThrowIfNull(points); - - Points = points - .Where(x => !string.IsNullOrWhiteSpace(x.Domain) && !string.IsNullOrWhiteSpace(x.MmsItemName)) - .OrderBy(x => x.Domain, StringComparer.OrdinalIgnoreCase) - .ThenBy(x => x.LogicalNode, StringComparer.OrdinalIgnoreCase) - .ThenBy(x => x.FunctionalConstraint, StringComparer.OrdinalIgnoreCase) - .ThenBy(x => x.DataObjectPath, StringComparer.OrdinalIgnoreCase) - .ToArray(); - - _logicalDevices = Points - .GroupBy(x => x.Domain, StringComparer.OrdinalIgnoreCase) - .ToDictionary( - x => x.Key, - x => new MmsLogicalDeviceDirectory(x.Key, x), - StringComparer.OrdinalIgnoreCase); - - _pointsByUserReference = Points - .GroupBy(x => x.UserReference, StringComparer.OrdinalIgnoreCase) - .ToDictionary( - x => x.Key, - x => x.ToList(), - StringComparer.OrdinalIgnoreCase); - - _pointsByMmsReference = Points - .GroupBy(x => x.MmsReference, StringComparer.OrdinalIgnoreCase) - .ToDictionary( - x => x.Key, - x => x.OrderByDescending(p => p.Confidence).First(), - StringComparer.OrdinalIgnoreCase); + Rebuild(points); } - public IReadOnlyList Points { get; } + public IReadOnlyList Points { get; private set; } = Array.Empty(); public IReadOnlyDictionary LogicalDevices => _logicalDevices; public int LogicalDeviceCount => _logicalDevices.Count; public int LogicalNodeCount => _logicalDevices.Values.Sum(x => x.LogicalNodes.Count); @@ -48,6 +20,35 @@ public MmsIedModelDirectory(IEnumerable points) public int ReportAttributeCount => Points.Count(x => x.IsReportAttribute); public int ControlAttributeCount => Points.Count(x => x.IsControlAttribute); + internal int AddSupplementalPoints(IEnumerable supplementalPoints) + { + ArgumentNullException.ThrowIfNull(supplementalPoints); + + var combined = new List(Points); + var knownMmsReferences = new HashSet( + Points.Select(point => point.MmsReference), + StringComparer.OrdinalIgnoreCase); + var added = 0; + + foreach (var point in supplementalPoints) + { + if (string.IsNullOrWhiteSpace(point.Domain) || + string.IsNullOrWhiteSpace(point.MmsItemName) || + !knownMmsReferences.Add(point.MmsReference)) + { + continue; + } + + combined.Add(point); + added++; + } + + if (added > 0) + Rebuild(combined); + + return added; + } + public IReadOnlyList FindByUserReference(string reference) { var normalized = MmsFcReferenceNormalizer.NormalizeUserReference(reference); @@ -84,6 +85,29 @@ public IReadOnlyDictionary CountByFunctionalConstraint() public string Summary => $"IED directory: LD={LogicalDeviceCount}, LN={LogicalNodeCount}, FC-points={PointCount}, reportAttrs={ReportAttributeCount}, controlAttrs={ControlAttributeCount}"; + + private void Rebuild(IEnumerable points) + { + Points = points + .Where(x => !string.IsNullOrWhiteSpace(x.Domain) && !string.IsNullOrWhiteSpace(x.MmsItemName)) + .OrderBy(x => x.Domain, StringComparer.OrdinalIgnoreCase) + .ThenBy(x => x.LogicalNode, StringComparer.OrdinalIgnoreCase) + .ThenBy(x => x.FunctionalConstraint, StringComparer.OrdinalIgnoreCase) + .ThenBy(x => x.DataObjectPath, StringComparer.OrdinalIgnoreCase) + .ToArray(); + + _logicalDevices.Clear(); + foreach (var group in Points.GroupBy(x => x.Domain, StringComparer.OrdinalIgnoreCase)) + _logicalDevices[group.Key] = new MmsLogicalDeviceDirectory(group.Key, group); + + _pointsByUserReference.Clear(); + foreach (var group in Points.GroupBy(x => x.UserReference, StringComparer.OrdinalIgnoreCase)) + _pointsByUserReference[group.Key] = group.ToList(); + + _pointsByMmsReference.Clear(); + foreach (var group in Points.GroupBy(x => x.MmsReference, StringComparer.OrdinalIgnoreCase)) + _pointsByMmsReference[group.Key] = group.OrderByDescending(point => point.Confidence).First(); + } } public sealed class MmsLogicalDeviceDirectory diff --git a/tests/AR.Iec61850.Tests/Mms/LiveIedTypeHierarchyPointMaterializerTests.cs b/tests/AR.Iec61850.Tests/Mms/LiveIedTypeHierarchyPointMaterializerTests.cs new file mode 100644 index 00000000..f4bd65ea --- /dev/null +++ b/tests/AR.Iec61850.Tests/Mms/LiveIedTypeHierarchyPointMaterializerTests.cs @@ -0,0 +1,142 @@ +using AR.Iec61850.Discovery; +using AR.Iec61850.Mms; + +namespace AR.Iec61850.Tests.Mms; + +public sealed class LiveIedTypeHierarchyPointMaterializerTests +{ + [Fact] + public void Build_MaterializesMissingNamedLnTreeLeavesWithoutReplacingObservedPoints() + { + var observed = new MmsFcResolvedPoint + { + Domain = "LD0", + LogicalNode = "CSWI1", + FunctionalConstraint = "ST", + DataObjectPath = "Pos.stVal", + MmsItemName = "CSWI1$ST$Pos$stVal", + Source = "LiveMmsGetNameList", + Confidence = 100 + }; + var directory = new MmsIedModelDirectory([observed]); + var result = new MmsVariableAccessAttributesResult + { + IsSuccess = true, + Reference = new MmsObjectReference("LD0", "CSWI1", string.Empty), + TypeSpecification = Structure( + "", + Structure( + "ST", + Structure( + "Pos", + Leaf("stVal", "bit-string", "Dbpos"), + Leaf("q", "bit-string", "Quality"), + Leaf("t", "utc-time", "Timestamp"), + Leaf("stSeld", "boolean", "BOOLEAN"))), + Structure( + "CO", + Structure( + "Pos", + Leaf("SBO", "visible-string", "ObjRef"), + Structure( + "Oper", + Leaf("ctlVal", "bit-string", "Dbpos"), + Leaf("Test", "boolean", "BOOLEAN")))), + Structure( + "CF", + Structure( + "Pos", + Leaf("ctlModel", "integer", "Enum"), + Leaf("sboTimeout", "unsigned", "INT32U"), + Leaf("operTimeout", "unsigned", "INT32U"))), + Structure( + "ZZ", + Structure( + "Ignored", + Leaf("value", "integer", "INT32"))), + new MmsTypeSpecificationNode + { + Name = "MX", + MmsType = "structure", + SclBType = "Struct", + Children = + [ + new MmsTypeSpecificationNode + { + Name = "Harm", + MmsType = "array", + SclBType = "Struct", + Children = [Leaf("element", "floating-point", "FLOAT32")] + } + ] + }) + }; + + var index = LiveIedVariableTypeHierarchyIndex.Build(directory, [result]); + + Assert.Equal(10, directory.PointCount); + Assert.True(directory.TryFindByMmsReference("LD0/CSWI1$CO$Pos$SBO", out var sbo)); + Assert.Equal("Pos.SBO", sbo.DataObjectPath); + Assert.Equal("GetVariableAccessAttributesLogicalNodeTree", sbo.Source); + Assert.True(index.TryResolve(sbo, out var sboType)); + Assert.Equal("ObjRef", sboType.TypeSpecification.SclBType); + + Assert.True(directory.TryFindByMmsReference("LD0/CSWI1$ST$Pos$stVal", out var retained)); + Assert.Equal("LiveMmsGetNameList", retained.Source); + + Assert.DoesNotContain(directory.Points, point => point.FunctionalConstraint == "ZZ"); + Assert.DoesNotContain(directory.Points, point => point.MmsItemName.Contains("$element", StringComparison.OrdinalIgnoreCase)); + } + + [Fact] + public void Build_DoesNotMaterializeHierarchyForUnknownLogicalNode() + { + var directory = new MmsIedModelDirectory( + [ + new MmsFcResolvedPoint + { + Domain = "LD0", + LogicalNode = "CSWI1", + FunctionalConstraint = "ST", + DataObjectPath = "Pos.stVal", + MmsItemName = "CSWI1$ST$Pos$stVal" + } + ]); + var foreign = new MmsVariableAccessAttributesResult + { + IsSuccess = true, + Reference = new MmsObjectReference("LD0", "XCBR9", string.Empty), + TypeSpecification = Structure( + "", + Structure("ST", Structure("Pos", Leaf("q", "bit-string", "Quality")))) + }; + + var index = LiveIedVariableTypeHierarchyIndex.Build(directory, [foreign]); + + Assert.Single(directory.Points); + Assert.False(directory.TryFindByMmsReference("LD0/XCBR9$ST$Pos$q", out _)); + Assert.Equal(0, index.ResolvedAttributeCount); + } + + private static MmsTypeSpecificationNode Structure( + string name, + params MmsTypeSpecificationNode[] children) + => new() + { + Name = name, + MmsType = "structure", + SclBType = "Struct", + Children = children + }; + + private static MmsTypeSpecificationNode Leaf( + string name, + string mmsType, + string sclBType) + => new() + { + Name = name, + MmsType = mmsType, + SclBType = sclBType + }; +} From 1b7cbbe8af3dfbc2a15086cb2c3e5eff02e006bb Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 16 Sep 2026 23:44:22 +0700 Subject: [PATCH 25/66] perf(discovery): add association-scoped smart single-flight --- ...lientSession.SmartDiscoverySingleFlight.cs | 149 ++++++++++++++++++ 1 file changed, 149 insertions(+) create mode 100644 src/AR.Iec61850/Mms/MmsClientSession.SmartDiscoverySingleFlight.cs diff --git a/src/AR.Iec61850/Mms/MmsClientSession.SmartDiscoverySingleFlight.cs b/src/AR.Iec61850/Mms/MmsClientSession.SmartDiscoverySingleFlight.cs new file mode 100644 index 00000000..9aaaf79c --- /dev/null +++ b/src/AR.Iec61850/Mms/MmsClientSession.SmartDiscoverySingleFlight.cs @@ -0,0 +1,149 @@ +namespace AR.Iec61850.Mms; + +/// +/// Association-scoped single-flight wrapper for smart discovery. Multiple consumers +/// asking for the same bounded discovery profile on one live MMS association share the +/// same wire operation. Cancelling one waiter never cancels the shared discovery. +/// +public sealed partial class MmsClientSession +{ + private readonly object _smartDiscoverySingleFlightSync = new(); + private object? _smartDiscoverySingleFlightAssociationMarker; + private string _smartDiscoverySingleFlightHost = string.Empty; + private int _smartDiscoverySingleFlightPort; + private SmartDiscoverySingleFlightKey? _smartDiscoverySingleFlightKey; + private Task? _smartDiscoverySingleFlightTask; + + /// + /// Runs at most once for an identical smart + /// discovery profile on the current accepted association. The underlying operation + /// is intentionally independent from an individual caller's cancellation token; + /// waiter cancellation only stops that waiter. Association loss still terminates + /// the shared operation through the normal transport/receive-pump fault path. + /// + public async Task DiscoverSmartSingleFlightAsync( + MmsSmartDiscoveryOptions? options = null, + CancellationToken cancellationToken = default) + { + EnsureMmsReady(); + options ??= new MmsSmartDiscoveryOptions(); + + var associationMarker = LastAssociationAttempts; + var host = _lastHost; + var port = _lastPort; + var key = SmartDiscoverySingleFlightKey.From(options); + + Task sharedTask; + lock (_smartDiscoverySingleFlightSync) + { + if (!IsSameSmartDiscoveryAssociation(associationMarker, host, port)) + ResetSmartDiscoverySingleFlightUnsafe(); + + if (_smartDiscoverySingleFlightTask != null && + _smartDiscoverySingleFlightKey.HasValue && + _smartDiscoverySingleFlightKey.Value.Equals(key)) + { + sharedTask = _smartDiscoverySingleFlightTask; + } + else + { + _smartDiscoverySingleFlightAssociationMarker = associationMarker; + _smartDiscoverySingleFlightHost = host; + _smartDiscoverySingleFlightPort = port; + _smartDiscoverySingleFlightKey = key; + + sharedTask = DiscoverSmartAsync(options, CancellationToken.None); + _smartDiscoverySingleFlightTask = sharedTask; + ObserveSmartDiscoverySingleFlightCompletion(sharedTask); + } + } + + return await sharedTask.WaitAsync(cancellationToken).ConfigureAwait(false); + } + + private void ObserveSmartDiscoverySingleFlightCompletion(Task task) + { + _ = task.ContinueWith( + completed => + { + var reusable = completed.Status == TaskStatus.RanToCompletion && + IsReusableSmartDiscoveryResult(completed.Result); + + if (reusable) + return; + + lock (_smartDiscoverySingleFlightSync) + { + if (ReferenceEquals(_smartDiscoverySingleFlightTask, completed)) + ResetSmartDiscoverySingleFlightUnsafe(); + } + }, + CancellationToken.None, + TaskContinuationOptions.ExecuteSynchronously, + TaskScheduler.Default); + } + + private bool IsSameSmartDiscoveryAssociation(object associationMarker, string host, int port) + => IsMmsInitiated && + ReferenceEquals(_smartDiscoverySingleFlightAssociationMarker, associationMarker) && + string.Equals(_smartDiscoverySingleFlightHost, host, StringComparison.OrdinalIgnoreCase) && + _smartDiscoverySingleFlightPort == port; + + private bool IsReusableSmartDiscoveryResult(MmsDiscoveryResult result) + { + if (!IsMmsInitiated || result.Snapshot.DomainCount <= 0) + return false; + + // DiscoverSmartAsync deliberately returns partial evidence for recoverable MMS + // failures. Partial evidence is useful to the initiating caller but must never + // poison the association cache; a later caller must be allowed to retry. + var summary = result.Summary ?? string.Empty; + return summary.Contains("domain-list=complete", StringComparison.OrdinalIgnoreCase) && + summary.Contains("incompleteChains=0", StringComparison.OrdinalIgnoreCase); + } + + private void ResetSmartDiscoverySingleFlightUnsafe() + { + _smartDiscoverySingleFlightAssociationMarker = null; + _smartDiscoverySingleFlightHost = string.Empty; + _smartDiscoverySingleFlightPort = 0; + _smartDiscoverySingleFlightKey = null; + _smartDiscoverySingleFlightTask = null; + } + + private readonly record struct SmartDiscoverySingleFlightKey( + int MaxConcurrentChains, + int UnknownPeerMaxConcurrentChains, + int MaxDomains, + int MaxVariableNamesPerDomain, + int MaxVariableListNamesPerDomain, + int MaxNameListPages, + bool ProbeReportAttributes, + int MaxReportAttributeProbes, + bool ReadDataSetDirectories, + int MaxDataSetDirectoryReads, + string PriorityDomains) + { + public static SmartDiscoverySingleFlightKey From(MmsSmartDiscoveryOptions options) + { + var priorityDomains = string.Join( + "\u001F", + (options.PriorityDomains ?? Array.Empty()) + .Where(domain => !string.IsNullOrWhiteSpace(domain)) + .Select(domain => domain.Trim().ToUpperInvariant())); + + return new SmartDiscoverySingleFlightKey( + options.MaxConcurrentChains, + options.UnknownPeerMaxConcurrentChains, + options.MaxDomains, + options.MaxVariableNamesPerDomain, + options.MaxVariableListNamesPerDomain, + options.MaxNameListPages, + options.ProbeReportAttributes, + options.MaxReportAttributeProbes, + options.ReadDataSetDirectories, + options.MaxDataSetDirectoryReads, + priorityDomains); + } + } +} From 7f1e7fc34bde96230731f8ce8fd7d328b4c75c5d Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 17 Sep 2026 07:26:53 +0700 Subject: [PATCH 26/66] Preserve exact DataSet member order and multiplicity --- src/AR.Iec61850/Mms/MmsDataSetDirectory.cs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/AR.Iec61850/Mms/MmsDataSetDirectory.cs b/src/AR.Iec61850/Mms/MmsDataSetDirectory.cs index 1c4b78ed..12917065 100644 --- a/src/AR.Iec61850/Mms/MmsDataSetDirectory.cs +++ b/src/AR.Iec61850/Mms/MmsDataSetDirectory.cs @@ -141,9 +141,11 @@ public static MmsDataSetDirectoryResult Decode( var rawMembers = new List<(string Domain, string Item)>(); DecodeServiceResponse(service, rawMembers, ref deletable); + // DataSet membership is positional evidence. Preserve the exact BER traversal + // order and multiplicity returned by the IED: report values are indexed against + // this sequence, so member-level de-duplication can silently corrupt projection. var members = rawMembers .Where(x => !string.IsNullOrWhiteSpace(x.Domain) && !string.IsNullOrWhiteSpace(x.Item)) - .DistinctBy(x => $"{x.Domain}/{x.Item}", StringComparer.OrdinalIgnoreCase) .Select(x => NormalizeMember(x.Domain, x.Item, iedDirectory)) .ToArray(); From 19bb0ae996a05be8c60f8ad03d03b0c53922a8b1 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 17 Sep 2026 07:27:09 +0700 Subject: [PATCH 27/66] Lock exact DataSet member ordering contract --- .../MmsDataSetCompletenessRegressionTests.cs | 57 +++++++++++++++++++ 1 file changed, 57 insertions(+) create mode 100644 tests/AR.Iec61850.Tests/Mms/MmsDataSetCompletenessRegressionTests.cs diff --git a/tests/AR.Iec61850.Tests/Mms/MmsDataSetCompletenessRegressionTests.cs b/tests/AR.Iec61850.Tests/Mms/MmsDataSetCompletenessRegressionTests.cs new file mode 100644 index 00000000..c1f38c75 --- /dev/null +++ b/tests/AR.Iec61850.Tests/Mms/MmsDataSetCompletenessRegressionTests.cs @@ -0,0 +1,57 @@ +using AR.Iec61850.Asn1; +using AR.Iec61850.Mms; + +namespace AR.Iec61850.Tests.Mms; + +public sealed class MmsDataSetCompletenessRegressionTests +{ + [Fact] + public void Decode_PreservesExactMemberOrderAndMultiplicity() + { + var first = BuildMember("LD0", "GGIO1$ST$Ind1$stVal"); + var second = BuildMember("LD0", "MMXU1$MX$A$phsA$cVal$mag$f"); + var repeatedFirst = BuildMember("LD0", "GGIO1$ST$Ind1$stVal"); + + var listOfVariable = BerWriter.EncodeTlv( + 0xA1, + first.Concat(second).Concat(repeatedFirst).ToArray()); + var service = BerWriter.EncodeTlv( + 0xAC, + BerWriter.EncodeTlv(0x80, [0x00]) + .Concat(listOfVariable) + .ToArray()); + var response = BerWriter.EncodeTlv( + 0xA1, + new byte[] { 0x02, 0x01, 0x01 } + .Concat(service) + .ToArray()); + + var result = MmsDataSetDirectoryResponseDecoder.Decode( + response, + expectedInvokeId: 1, + dataSetReference: "LD0/LLN0.Events"); + + Assert.True(result.IsSuccess, result.Message); + Assert.Equal(3, result.Members.Count); + Assert.Equal( + [ + "LD0/GGIO1$ST$Ind1$stVal", + "LD0/MMXU1$MX$A$phsA$cVal$mag$f", + "LD0/GGIO1$ST$Ind1$stVal" + ], + result.Members.Select(member => member.MmsReference).ToArray()); + } + + private static byte[] BuildMember(string domain, string item) + { + var memberObjectName = BerWriter.EncodeTlv( + 0xA1, + BerWriter.EncodeTlv(0x1A, BerWriter.EncodeAscii(domain)) + .Concat(BerWriter.EncodeTlv(0x1A, BerWriter.EncodeAscii(item))) + .ToArray()); + + return BerWriter.EncodeTlv( + 0x30, + BerWriter.EncodeTlv(0xA0, memberObjectName)); + } +} From 9920e8d82ed3bceb6af7a50a681e06dfbb5e9ce2 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 17 Sep 2026 07:27:49 +0700 Subject: [PATCH 28/66] fix(discovery): expose authoritative smart name inventory --- ...lientSession.SmartDiscoverySingleFlight.cs | 44 +++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/src/AR.Iec61850/Mms/MmsClientSession.SmartDiscoverySingleFlight.cs b/src/AR.Iec61850/Mms/MmsClientSession.SmartDiscoverySingleFlight.cs index 9aaaf79c..59e36114 100644 --- a/src/AR.Iec61850/Mms/MmsClientSession.SmartDiscoverySingleFlight.cs +++ b/src/AR.Iec61850/Mms/MmsClientSession.SmartDiscoverySingleFlight.cs @@ -61,6 +61,50 @@ public async Task DiscoverSmartSingleFlightAsync( return await sharedTask.WaitAsync(cancellationToken).ConfigureAwait(false); } + /// + /// Returns the domain-variable inventory already established by the authoritative + /// smart discovery on this association. Consumers such as Control must reuse this + /// evidence instead of issuing another VMD + per-domain GetNameList sweep. + /// + /// If no complete smart inventory exists for the current association, the legacy + /// browse remains the compatibility fallback. This keeps standalone control usage + /// safe while making a smart-discovered session wire-idempotent for later consumers. + /// + internal async Task>> GetAuthoritativeDomainVariableNamesAsync( + CancellationToken cancellationToken = default) + { + EnsureMmsReady(); + + Task? sharedTask = null; + lock (_smartDiscoverySingleFlightSync) + { + if (IsSameSmartDiscoveryAssociation(LastAssociationAttempts, _lastHost, _lastPort)) + sharedTask = _smartDiscoverySingleFlightTask; + } + + if (sharedTask != null) + { + try + { + var discovery = await sharedTask.WaitAsync(cancellationToken).ConfigureAwait(false); + if (IsReusableSmartDiscoveryResult(discovery)) + return discovery.Snapshot.DomainVariables; + } + catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) + { + // The shared discovery owns its own lifetime. If it ended unexpectedly, + // fall through to the compatibility browse while the association lives. + } + catch (Exception) when (IsMmsInitiated) + { + // Do not let a faulted/partial shared generation poison later consumers. + // A live association may still support the conservative legacy browse. + } + } + + return await DiscoverDomainVariableNamesAsync(cancellationToken).ConfigureAwait(false); + } + private void ObserveSmartDiscoverySingleFlightCompletion(Task task) { _ = task.ContinueWith( From 7a86b5903df3ffaa694e2416f191c155fbf3cbd4 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 17 Sep 2026 07:27:59 +0700 Subject: [PATCH 29/66] fix(control): reuse authoritative smart directory inventory --- src/AR.Iec61850/Control/Iec61850ControlTransport.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/AR.Iec61850/Control/Iec61850ControlTransport.cs b/src/AR.Iec61850/Control/Iec61850ControlTransport.cs index 31e42f47..b323d819 100644 --- a/src/AR.Iec61850/Control/Iec61850ControlTransport.cs +++ b/src/AR.Iec61850/Control/Iec61850ControlTransport.cs @@ -41,7 +41,7 @@ public Task GetVariableSpecificationAsync(Mms => _session.GetVariableAccessAttributesAsync(reference, cancellationToken); public Task>> DiscoverDomainVariablesAsync(CancellationToken cancellationToken) - => _session.DiscoverDomainVariableNamesAsync(cancellationToken); + => _session.GetAuthoritativeDomainVariableNamesAsync(cancellationToken); public IAsyncDisposable SubscribeInformationReports(out ChannelReader reader, int capacity = 32) { From 94b9bbb99f65e01d03af809827fe1fc410258bdd Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 17 Sep 2026 07:48:08 +0700 Subject: [PATCH 30/66] perf(control): reuse authoritative discovery inventory --- .../Control/Iec61850ControlService.cs | 43 ++++++++++++++++++- 1 file changed, 41 insertions(+), 2 deletions(-) diff --git a/src/AR.Iec61850/Control/Iec61850ControlService.cs b/src/AR.Iec61850/Control/Iec61850ControlService.cs index e2099f3e..30b4051f 100644 --- a/src/AR.Iec61850/Control/Iec61850ControlService.cs +++ b/src/AR.Iec61850/Control/Iec61850ControlService.cs @@ -17,12 +17,40 @@ public Task OpenAsync( CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(session); - return OpenCoreAsync(new MmsClientControlTransport(session), objectReference, cancellationToken); + return OpenCoreAsync(new MmsClientControlTransport(session), objectReference, null, cancellationToken); } + /// + /// Opens a control object while reusing an authoritative name inventory already + /// discovered on the same MMS association. The supplied inventory is structural + /// evidence only: ctlModel, exact control type specifications, timeout values and + /// status values remain live reads/probes so command safety stays fail-closed. + /// + public Task OpenAsync( + MmsClientSession session, + string objectReference, + IReadOnlyDictionary> authoritativeDomainVariables, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(session); + ArgumentNullException.ThrowIfNull(authoritativeDomainVariables); + return OpenCoreAsync( + new MmsClientControlTransport(session), + objectReference, + authoritativeDomainVariables, + cancellationToken); + } + + internal Task OpenCoreAsync( + IIec61850ControlTransport transport, + string objectReference, + CancellationToken cancellationToken = default) + => OpenCoreAsync(transport, objectReference, null, cancellationToken); + internal async Task OpenCoreAsync( IIec61850ControlTransport transport, string objectReference, + IReadOnlyDictionary>? authoritativeDomainVariables, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(transport); @@ -65,7 +93,18 @@ internal async Task OpenCoreAsync( evidence.Add($"Cancel={cancelSpecification.Signature}"); } - var namesByDomain = await transport.DiscoverDomainVariablesAsync(cancellationToken).ConfigureAwait(false); + IReadOnlyDictionary> namesByDomain; + if (authoritativeDomainVariables != null) + { + namesByDomain = authoritativeDomainVariables; + evidence.Add("domainInventory=authoritative-reuse"); + } + else + { + namesByDomain = await transport.DiscoverDomainVariablesAsync(cancellationToken).ConfigureAwait(false); + evidence.Add("domainInventory=live-fallback"); + } + namesByDomain.TryGetValue(references.Domain, out var domainNames); domainNames ??= Array.Empty(); From 57c8311c0dcf9e51da4d7dc31c757fc3f0912586 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 17 Sep 2026 07:48:32 +0700 Subject: [PATCH 31/66] test(control): prevent duplicate directory browse with authority --- .../AuthoritativeControlInventoryTests.cs | 144 ++++++++++++++++++ 1 file changed, 144 insertions(+) create mode 100644 tests/AR.Iec61850.Tests/Control/AuthoritativeControlInventoryTests.cs diff --git a/tests/AR.Iec61850.Tests/Control/AuthoritativeControlInventoryTests.cs b/tests/AR.Iec61850.Tests/Control/AuthoritativeControlInventoryTests.cs new file mode 100644 index 00000000..ed57fb08 --- /dev/null +++ b/tests/AR.Iec61850.Tests/Control/AuthoritativeControlInventoryTests.cs @@ -0,0 +1,144 @@ +using AR.Iec61850.Control; +using AR.Iec61850.Mms; +using System.Threading.Channels; + +namespace AR.Iec61850.Tests.Control; + +public sealed class AuthoritativeControlInventoryTests +{ + [Fact] + public async Task OpenCore_WithAuthoritativeInventory_DoesNotBrowseDomainVariablesAgain() + { + var transport = new CountingTransport(); + var service = new Iec61850ControlService(); + var authority = new Dictionary>(StringComparer.OrdinalIgnoreCase) + { + ["LD0"] = new[] { "CSWI1$ST$Pos$stVal" } + }; + + await using var session = await service.OpenCoreAsync( + transport, + "LD0/CSWI1.Pos", + authority, + CancellationToken.None); + + Assert.Equal(0, transport.DomainDiscoveryCalls); + Assert.Equal("LD0/CSWI1.Pos.stVal", session.Descriptor.StatusReference); + Assert.Equal("ST", session.Descriptor.StatusFunctionalConstraint); + Assert.Contains("domainInventory=authoritative-reuse", session.Descriptor.DiscoveryEvidence, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task OpenCore_WithoutAuthoritativeInventory_PreservesLegacyFallback() + { + var transport = new CountingTransport(); + var service = new Iec61850ControlService(); + + await using var session = await service.OpenCoreAsync( + transport, + "LD0/CSWI1.Pos", + CancellationToken.None); + + Assert.Equal(1, transport.DomainDiscoveryCalls); + Assert.Equal("LD0/CSWI1.Pos.stVal", session.Descriptor.StatusReference); + Assert.Contains("domainInventory=live-fallback", session.Descriptor.DiscoveryEvidence, StringComparison.OrdinalIgnoreCase); + } + + private sealed class CountingTransport : IIec61850ControlTransport + { + private readonly Channel _reports = Channel.CreateUnbounded(); + + public object AssociationIdentity { get; } = new(); + public bool IsAssociated => true; + public string LastRequestHex => string.Empty; + public string LastResponseHex => string.Empty; + public int DomainDiscoveryCalls { get; private set; } + + public Task ReadAsync(MmsObjectReference reference, CancellationToken cancellationToken) + { + if (reference.Item.EndsWith("$ctlModel", StringComparison.OrdinalIgnoreCase)) + { + return Task.FromResult(new MmsReadResult + { + IsSuccess = true, + Value = MmsDataValue.Integer(3) + }); + } + + return Task.FromResult(new MmsReadResult + { + IsSuccess = false, + Message = "optional value not configured" + }); + } + + public Task GetVariableSpecificationAsync( + MmsObjectReference reference, + CancellationToken cancellationToken) + { + var configured = reference.Item.EndsWith("$Oper", StringComparison.OrdinalIgnoreCase) || + reference.Item.EndsWith("$Cancel", StringComparison.OrdinalIgnoreCase); + return Task.FromResult(new MmsVariableAccessAttributesResult + { + IsSuccess = configured, + Reference = reference, + TypeSpecification = configured ? CommandSpecification() : null, + Message = configured ? "ok" : "not configured" + }); + } + + public Task>> DiscoverDomainVariablesAsync( + CancellationToken cancellationToken) + { + DomainDiscoveryCalls++; + IReadOnlyDictionary> names = + new Dictionary>(StringComparer.OrdinalIgnoreCase) + { + ["LD0"] = new[] { "CSWI1$ST$Pos$stVal" } + }; + return Task.FromResult(names); + } + + public Task WriteControlAsync( + MmsObjectReference reference, + MmsDataValue value, + CancellationToken cancellationToken) + => Task.FromResult(new MmsWriteResult { IsSuccess = true }); + + public IAsyncDisposable SubscribeInformationReports(out ChannelReader reader, int capacity = 32) + { + reader = _reports.Reader; + return new NoopSubscription(); + } + + private static MmsTypeSpecificationNode CommandSpecification() + => new() + { + MmsType = "structure", + Children = new MmsTypeSpecificationNode[] + { + new() { Name = "ctlVal", MmsType = "bit-string", Size = 2 }, + new() { Name = "operTm", MmsType = "utc-time" }, + new() + { + Name = "origin", + MmsType = "structure", + Children = new MmsTypeSpecificationNode[] + { + new() { Name = "orCat", MmsType = "integer" }, + new() { Name = "orIdent", MmsType = "octet-string", Size = 64 } + } + }, + new() { Name = "ctlNum", MmsType = "unsigned" }, + new() { Name = "T", MmsType = "utc-time" }, + new() { Name = "Test", MmsType = "boolean" }, + new() { Name = "Check", MmsType = "bit-string", Size = 2 } + } + }; + + private sealed class NoopSubscription : IAsyncDisposable + { + public ValueTask DisposeAsync() => ValueTask.CompletedTask; + } + } +} From a54be1f90d4b54b50940196182e4f4d6c001149e Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 17 Sep 2026 08:24:23 +0700 Subject: [PATCH 32/66] feat(discovery): add zero-traffic smart KPI recorder --- src/AR.Iec61850/Mms/MmsSmartDiscoveryKpi.cs | 415 ++++++++++++++++++++ 1 file changed, 415 insertions(+) create mode 100644 src/AR.Iec61850/Mms/MmsSmartDiscoveryKpi.cs diff --git a/src/AR.Iec61850/Mms/MmsSmartDiscoveryKpi.cs b/src/AR.Iec61850/Mms/MmsSmartDiscoveryKpi.cs new file mode 100644 index 00000000..a882e61f --- /dev/null +++ b/src/AR.Iec61850/Mms/MmsSmartDiscoveryKpi.cs @@ -0,0 +1,415 @@ +using System.Diagnostics; +using System.Security.Cryptography; +using System.Text; + +namespace AR.Iec61850.Mms; + +public sealed class MmsSmartDiscoveryKpiPhaseSnapshot +{ + public string Phase { get; init; } = string.Empty; + public int Requests { get; init; } + public int SuccessfulRequests { get; init; } + public int FailedRequests { get; init; } + public int DuplicateRequests { get; init; } + public double TotalLatencyMs { get; init; } + public double AverageLatencyMs { get; init; } + public double MaxLatencyMs { get; init; } +} + +/// +/// Zero-traffic observability snapshot for one smart-discovery generation. +/// Request counts come only from requests that ARSAS was already going to send. +/// Timing is measured around the existing confirmed-service call sites; no probe, +/// ping, Read, GVA, or GetNameList is added for KPI collection. +/// +public sealed class MmsSmartDiscoveryKpiSnapshot +{ + public long Generation { get; init; } + public DateTimeOffset StartedAtUtc { get; init; } + public double ElapsedMs { get; init; } + public int TotalRequests { get; init; } + public int SuccessfulRequests { get; init; } + public int FailedRequests { get; init; } + public int DuplicateRequests { get; init; } + public int PeakOutstandingRequests { get; init; } + public int LogicalDeviceCount { get; init; } + public int LogicalNodeCount { get; init; } + public int RawVariableCount { get; init; } + public int FcPointCount { get; init; } + public int DataSetCount { get; init; } + public int DataSetDirectoryCount { get; init; } + public int DataSetMemberCount { get; init; } + public int ReportControlCount { get; init; } + public int BufferedReportControlCount { get; init; } + public int UnbufferedReportControlCount { get; init; } + public IReadOnlyList Phases { get; init; } = Array.Empty(); + public string DeterministicSignature { get; init; } = string.Empty; + + public string Summary => + $"Smart discovery KPI: generation={Generation}, requests={TotalRequests}, success={SuccessfulRequests}, " + + $"failed={FailedRequests}, duplicates={DuplicateRequests}, peakOutstanding={PeakOutstandingRequests}, " + + $"elapsed={ElapsedMs:0.0} ms, LD={LogicalDeviceCount}, LN={LogicalNodeCount}, " + + $"raw={RawVariableCount}, FC-points={FcPointCount}, datasets={DataSetCount}, " + + $"datasetDirectories={DataSetDirectoryCount}, datasetMembers={DataSetMemberCount}, " + + $"RCB={ReportControlCount}, signature={DeterministicSignature}."; +} + +public sealed partial class MmsClientSession +{ + private static long _nextSmartDiscoveryKpiGeneration; + private MmsSmartDiscoveryKpiRecorder? _smartDiscoveryKpiRecorder; + + /// + /// Most recent point-in-time KPI snapshot. The deterministic signature excludes + /// wall-clock timing and worker completion order so equivalent discovery evidence + /// produces the same signature across repeat runs. + /// + public MmsSmartDiscoveryKpiSnapshot? LastSmartDiscoveryKpi + => Volatile.Read(ref _smartDiscoveryKpiRecorder)?.Snapshot(); + + private void BeginSmartDiscoveryKpiGeneration() + { + var generation = Interlocked.Increment(ref _nextSmartDiscoveryKpiGeneration); + Volatile.Write(ref _smartDiscoveryKpiRecorder, new MmsSmartDiscoveryKpiRecorder(generation)); + } + + private MmsSmartDiscoveryRequestObservation ObserveSmartDiscoveryRequest( + string phase, + string service, + string logicalKey) + => Volatile.Read(ref _smartDiscoveryKpiRecorder)?.BeginRequest(phase, service, logicalKey) + ?? MmsSmartDiscoveryRequestObservation.Noop; + + private void UpdateSmartDiscoveryCompleteness( + MmsDiscoverySnapshot snapshot, + MmsIedModelDirectory directory, + MmsReportInventory inventory, + IReadOnlyList dataSetDirectories) + { + var recorder = Volatile.Read(ref _smartDiscoveryKpiRecorder); + if (recorder is null) + return; + + recorder.UpdateCompleteness( + directory.LogicalDeviceCount, + directory.LogicalNodeCount, + snapshot.RawVariableCount, + directory.PointCount, + inventory.DataSets.Count, + dataSetDirectories.Count(result => result.IsSuccess), + dataSetDirectories.Where(result => result.IsSuccess).Sum(result => result.Members.Count), + inventory.ReportControls.Count, + inventory.BufferedCount, + inventory.UnbufferedCount); + } + + /// + /// Refreshes model completeness after later semantic materialization has enriched + /// the same live directory. This is local bookkeeping only and sends no MMS traffic. + /// + public void RefreshSmartDiscoveryModelKpi(MmsIedModelDirectory directory) + { + ArgumentNullException.ThrowIfNull(directory); + Volatile.Read(ref _smartDiscoveryKpiRecorder)?.UpdateModelCompleteness( + directory.LogicalDeviceCount, + directory.LogicalNodeCount, + directory.PointCount); + } + + private async Task> GetObservedSmartDataSetDirectoriesAsync( + IReadOnlyList dataSetReferences, + MmsIedModelDirectory directory, + CancellationToken cancellationToken) + { + if (dataSetReferences.Count == 0) + return Array.Empty(); + + var results = new List(dataSetReferences.Count); + foreach (var reference in dataSetReferences) + { + cancellationToken.ThrowIfCancellationRequested(); + using var observation = ObserveSmartDiscoveryRequest( + "dataset-directory", + "GetNamedVariableListAttributes", + reference); + + var result = await GetDataSetDirectoryAsync(reference, directory, cancellationToken) + .ConfigureAwait(false); + observation.Complete(result.IsSuccess); + results.Add(result); + + if (!IsMmsInitiated) + break; + } + + return results; + } +} + +internal sealed class MmsSmartDiscoveryKpiRecorder +{ + private readonly object _gate = new(); + private readonly long _startedTimestamp = Stopwatch.GetTimestamp(); + private readonly Dictionary _requestStats = new(StringComparer.Ordinal); + private readonly Dictionary _phaseStats = new(StringComparer.OrdinalIgnoreCase); + private int _totalRequests; + private int _successfulRequests; + private int _failedRequests; + private int _duplicateRequests; + private int _outstandingRequests; + private int _peakOutstandingRequests; + private int _logicalDeviceCount; + private int _logicalNodeCount; + private int _rawVariableCount; + private int _fcPointCount; + private int _dataSetCount; + private int _dataSetDirectoryCount; + private int _dataSetMemberCount; + private int _reportControlCount; + private int _bufferedReportControlCount; + private int _unbufferedReportControlCount; + + public MmsSmartDiscoveryKpiRecorder(long generation) + { + Generation = generation; + StartedAtUtc = DateTimeOffset.UtcNow; + } + + public long Generation { get; } + public DateTimeOffset StartedAtUtc { get; } + + public MmsSmartDiscoveryRequestObservation BeginRequest(string phase, string service, string logicalKey) + { + var normalizedPhase = Normalize(phase, "unknown"); + var normalizedService = Normalize(service, "unknown"); + var normalizedKey = Normalize(logicalKey, ""); + var descriptor = $"{normalizedPhase}|{normalizedService}|{normalizedKey}"; + + lock (_gate) + { + _totalRequests++; + _outstandingRequests++; + _peakOutstandingRequests = Math.Max(_peakOutstandingRequests, _outstandingRequests); + + if (!_phaseStats.TryGetValue(normalizedPhase, out var phaseStats)) + { + phaseStats = new MutablePhaseStats(normalizedPhase); + _phaseStats.Add(normalizedPhase, phaseStats); + } + phaseStats.Requests++; + + if (!_requestStats.TryGetValue(descriptor, out var requestStats)) + { + requestStats = new MutableRequestStats(descriptor); + _requestStats.Add(descriptor, requestStats); + } + else + { + _duplicateRequests++; + phaseStats.DuplicateRequests++; + } + requestStats.Attempts++; + } + + return new MmsSmartDiscoveryRequestObservation(this, normalizedPhase, descriptor, Stopwatch.GetTimestamp()); + } + + public void UpdateCompleteness( + int logicalDeviceCount, + int logicalNodeCount, + int rawVariableCount, + int fcPointCount, + int dataSetCount, + int dataSetDirectoryCount, + int dataSetMemberCount, + int reportControlCount, + int bufferedReportControlCount, + int unbufferedReportControlCount) + { + lock (_gate) + { + _logicalDeviceCount = Math.Max(0, logicalDeviceCount); + _logicalNodeCount = Math.Max(0, logicalNodeCount); + _rawVariableCount = Math.Max(0, rawVariableCount); + _fcPointCount = Math.Max(0, fcPointCount); + _dataSetCount = Math.Max(0, dataSetCount); + _dataSetDirectoryCount = Math.Max(0, dataSetDirectoryCount); + _dataSetMemberCount = Math.Max(0, dataSetMemberCount); + _reportControlCount = Math.Max(0, reportControlCount); + _bufferedReportControlCount = Math.Max(0, bufferedReportControlCount); + _unbufferedReportControlCount = Math.Max(0, unbufferedReportControlCount); + } + } + + public void UpdateModelCompleteness(int logicalDeviceCount, int logicalNodeCount, int fcPointCount) + { + lock (_gate) + { + _logicalDeviceCount = Math.Max(0, logicalDeviceCount); + _logicalNodeCount = Math.Max(0, logicalNodeCount); + _fcPointCount = Math.Max(0, fcPointCount); + } + } + + public MmsSmartDiscoveryKpiSnapshot Snapshot() + { + lock (_gate) + { + var phases = _phaseStats.Values + .OrderBy(phase => phase.Phase, StringComparer.OrdinalIgnoreCase) + .Select(phase => new MmsSmartDiscoveryKpiPhaseSnapshot + { + Phase = phase.Phase, + Requests = phase.Requests, + SuccessfulRequests = phase.SuccessfulRequests, + FailedRequests = phase.FailedRequests, + DuplicateRequests = phase.DuplicateRequests, + TotalLatencyMs = phase.TotalLatencyMs, + AverageLatencyMs = phase.Requests == 0 ? 0 : phase.TotalLatencyMs / phase.Requests, + MaxLatencyMs = phase.MaxLatencyMs + }) + .ToArray(); + + return new MmsSmartDiscoveryKpiSnapshot + { + Generation = Generation, + StartedAtUtc = StartedAtUtc, + ElapsedMs = Stopwatch.GetElapsedTime(_startedTimestamp).TotalMilliseconds, + TotalRequests = _totalRequests, + SuccessfulRequests = _successfulRequests, + FailedRequests = _failedRequests, + DuplicateRequests = _duplicateRequests, + PeakOutstandingRequests = _peakOutstandingRequests, + LogicalDeviceCount = _logicalDeviceCount, + LogicalNodeCount = _logicalNodeCount, + RawVariableCount = _rawVariableCount, + FcPointCount = _fcPointCount, + DataSetCount = _dataSetCount, + DataSetDirectoryCount = _dataSetDirectoryCount, + DataSetMemberCount = _dataSetMemberCount, + ReportControlCount = _reportControlCount, + BufferedReportControlCount = _bufferedReportControlCount, + UnbufferedReportControlCount = _unbufferedReportControlCount, + Phases = phases, + DeterministicSignature = BuildDeterministicSignature() + }; + } + } + + internal void CompleteRequest(string phase, string descriptor, long startedTimestamp, bool success) + { + var elapsedMs = Stopwatch.GetElapsedTime(startedTimestamp).TotalMilliseconds; + lock (_gate) + { + _outstandingRequests = Math.Max(0, _outstandingRequests - 1); + if (success) + _successfulRequests++; + else + _failedRequests++; + + if (_requestStats.TryGetValue(descriptor, out var requestStats)) + { + if (success) + requestStats.SuccessfulAttempts++; + else + requestStats.FailedAttempts++; + } + + if (_phaseStats.TryGetValue(phase, out var phaseStats)) + { + if (success) + phaseStats.SuccessfulRequests++; + else + phaseStats.FailedRequests++; + phaseStats.TotalLatencyMs += elapsedMs; + phaseStats.MaxLatencyMs = Math.Max(phaseStats.MaxLatencyMs, elapsedMs); + } + } + } + + private string BuildDeterministicSignature() + { + var builder = new StringBuilder(); + foreach (var request in _requestStats.Values.OrderBy(item => item.Descriptor, StringComparer.Ordinal)) + { + builder.Append(request.Descriptor) + .Append("|attempts=").Append(request.Attempts) + .Append("|ok=").Append(request.SuccessfulAttempts) + .Append("|fail=").Append(request.FailedAttempts) + .Append('\n'); + } + + builder.Append("model|ld=").Append(_logicalDeviceCount) + .Append("|ln=").Append(_logicalNodeCount) + .Append("|raw=").Append(_rawVariableCount) + .Append("|fc=").Append(_fcPointCount) + .Append("|ds=").Append(_dataSetCount) + .Append("|dsdir=").Append(_dataSetDirectoryCount) + .Append("|dsmembers=").Append(_dataSetMemberCount) + .Append("|rcb=").Append(_reportControlCount) + .Append("|brcb=").Append(_bufferedReportControlCount) + .Append("|urcb=").Append(_unbufferedReportControlCount); + + var hash = SHA256.HashData(Encoding.UTF8.GetBytes(builder.ToString())); + return Convert.ToHexString(hash).ToLowerInvariant(); + } + + private static string Normalize(string? value, string fallback) + => string.IsNullOrWhiteSpace(value) + ? fallback + : value.Trim().Replace('\r', ' ').Replace('\n', ' ').ToLowerInvariant(); + + private sealed class MutableRequestStats + { + public MutableRequestStats(string descriptor) => Descriptor = descriptor; + public string Descriptor { get; } + public int Attempts { get; set; } + public int SuccessfulAttempts { get; set; } + public int FailedAttempts { get; set; } + } + + private sealed class MutablePhaseStats + { + public MutablePhaseStats(string phase) => Phase = phase; + public string Phase { get; } + public int Requests { get; set; } + public int SuccessfulRequests { get; set; } + public int FailedRequests { get; set; } + public int DuplicateRequests { get; set; } + public double TotalLatencyMs { get; set; } + public double MaxLatencyMs { get; set; } + } +} + +internal sealed class MmsSmartDiscoveryRequestObservation : IDisposable +{ + public static MmsSmartDiscoveryRequestObservation Noop { get; } = new(null, string.Empty, string.Empty, 0); + + private readonly MmsSmartDiscoveryKpiRecorder? _recorder; + private readonly string _phase; + private readonly string _descriptor; + private readonly long _startedTimestamp; + private int _completed; + + public MmsSmartDiscoveryRequestObservation( + MmsSmartDiscoveryKpiRecorder? recorder, + string phase, + string descriptor, + long startedTimestamp) + { + _recorder = recorder; + _phase = phase; + _descriptor = descriptor; + _startedTimestamp = startedTimestamp; + } + + public void Complete(bool success) + { + if (_recorder is null || Interlocked.Exchange(ref _completed, 1) != 0) + return; + + _recorder.CompleteRequest(_phase, _descriptor, _startedTimestamp, success); + } + + public void Dispose() => Complete(success: false); +} From c2fea41c12137513be1a373945176d9e3fe3363b Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 17 Sep 2026 08:24:50 +0700 Subject: [PATCH 33/66] feat(discovery): capture smart GVA KPI --- .../MmsClientSession.SmartVariableAccessAttributes.cs | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/AR.Iec61850/Mms/MmsClientSession.SmartVariableAccessAttributes.cs b/src/AR.Iec61850/Mms/MmsClientSession.SmartVariableAccessAttributes.cs index f88bfeeb..5255d2d2 100644 --- a/src/AR.Iec61850/Mms/MmsClientSession.SmartVariableAccessAttributes.cs +++ b/src/AR.Iec61850/Mms/MmsClientSession.SmartVariableAccessAttributes.cs @@ -202,9 +202,16 @@ private async Task ReadVariableAttributesSafe if (!IsMmsInitiated) return BuildUnavailableVariableTypeResult(reference, "MMS association is unavailable."); + using var observation = ObserveSmartDiscoveryRequest( + "type-enrichment", + "GetVariableAccessAttributes", + $"{reference.Domain}/{reference.Item}"); + try { - return await GetVariableAccessAttributesAsync(reference, cancellationToken).ConfigureAwait(false); + var result = await GetVariableAccessAttributesAsync(reference, cancellationToken).ConfigureAwait(false); + observation.Complete(result.IsSuccess); + return result; } catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) { From 1d987f711739dd5846a27b00ec3f597aa17464ef Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 17 Sep 2026 08:25:17 +0700 Subject: [PATCH 34/66] feat(discovery): capture smart initial-read KPI --- .../Mms/MmsClientSession.SmartInitialFcRead.cs | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/AR.Iec61850/Mms/MmsClientSession.SmartInitialFcRead.cs b/src/AR.Iec61850/Mms/MmsClientSession.SmartInitialFcRead.cs index e1f3bf20..ca8a6b90 100644 --- a/src/AR.Iec61850/Mms/MmsClientSession.SmartInitialFcRead.cs +++ b/src/AR.Iec61850/Mms/MmsClientSession.SmartInitialFcRead.cs @@ -198,6 +198,10 @@ private async Task ExecuteSmartInitialReadBatchAsyn references, MmsReadPayloadProfile.PresentationDataValues); + using var observation = ObserveSmartDiscoveryRequest( + "initial-read", + "Read", + BuildSmartInitialReadLogicalKey(references)); using var deadline = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); deadline.CancelAfter(timeout); @@ -210,6 +214,7 @@ private async Task ExecuteSmartInitialReadBatchAsyn deadline.Token) .ConfigureAwait(false); read = MmsReadBatchCodec.DecodeResponse(response, references, invokeId); + observation.Complete(read.IsSuccess); } catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested && deadline.IsCancellationRequested) { @@ -248,6 +253,12 @@ private async Task ExecuteSmartInitialReadBatchAsyn RequiresAssociationReset: false); } + private static string BuildSmartInitialReadLogicalKey(IReadOnlyList references) + => string.Join( + ";", + references.Select(reference => + $"{reference.Domain}/{reference.Item}/{reference.FunctionalConstraint}")); + private static InitialFcReadBatchExecution BuildSmartInitialReadExecution( InitialFcReadBatch batch, MmsReadBatchResult read) From 1f557c08553388f14e68f38fe39584b383934f47 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 17 Sep 2026 08:26:09 +0700 Subject: [PATCH 35/66] feat(discovery): capture structure and dataset KPI --- .../Mms/MmsClientSession.SmartDiscovery.cs | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/src/AR.Iec61850/Mms/MmsClientSession.SmartDiscovery.cs b/src/AR.Iec61850/Mms/MmsClientSession.SmartDiscovery.cs index 83e78e62..5be42685 100644 --- a/src/AR.Iec61850/Mms/MmsClientSession.SmartDiscovery.cs +++ b/src/AR.Iec61850/Mms/MmsClientSession.SmartDiscovery.cs @@ -63,6 +63,7 @@ public async Task DiscoverSmartAsync( { EnsureMmsReady(); options ??= new MmsSmartDiscoveryOptions(); + BeginSmartDiscoveryKpiGeneration(); var maxDomains = Math.Clamp(options.MaxDomains, 1, 4096); var maxVariables = Math.Clamp(options.MaxVariableNamesPerDomain, 1, 100000); @@ -177,7 +178,7 @@ await EnrichReportInventoryAsync( { try { - dataSetDirectories = (await GetDataSetDirectoriesAsync( + dataSetDirectories = (await GetObservedSmartDataSetDirectoriesAsync( dataSetReferences, iedDirectory, cancellationToken) @@ -215,9 +216,15 @@ await EnrichReportInventoryAsync( ? "report enrichment=partial" : "report enrichment=completed"; + UpdateSmartDiscoveryCompleteness(snapshot, iedDirectory, inventory, dataSetDirectories); + var kpi = LastSmartDiscoveryKpi; + var kpiSummary = kpi is null + ? "kpi=unavailable" + : $"kpi requests={kpi.TotalRequests}, duplicates={kpi.DuplicateRequests}, peak={kpi.PeakOutstandingRequests}, signature={kpi.DeterministicSignature}"; + LastDiscoveryAttemptSummary = $"Smart discovery: {domainStatus}, chains={chains.Length}, incompleteChains={incompleteChains}, " + - $"window={effectiveWindow}, negotiatedCalling={negotiatedText}, {reportSummary}."; + $"window={effectiveWindow}, negotiatedCalling={negotiatedText}, {reportSummary}, {kpiSummary}."; return new MmsDiscoveryResult { @@ -372,6 +379,7 @@ private async Task GetNameListPagedSmartAsync( cancellationToken.ThrowIfCancellationRequested(); page++; + var requestContinueAfter = string.IsNullOrWhiteSpace(continueAfter) ? "" : continueAfter; var invokeId = NextInvokeId(); var request = MmsGetNameListRequest.Build( invokeId, @@ -380,6 +388,10 @@ private async Task GetNameListPagedSmartAsync( string.IsNullOrWhiteSpace(continueAfter) ? null : continueAfter); LastDiscoveryRequestHex = HexDump.ToCompactString(request); + using var observation = ObserveSmartDiscoveryRequest( + "structure", + "GetNameList", + $"{objectClass}|{domainId ?? "VMD"}|continueAfter={requestContinueAfter}"); try { var response = await SendConfirmedPresentationPayloadAsync( @@ -389,6 +401,7 @@ private async Task GetNameListPagedSmartAsync( .ConfigureAwait(false); last = MmsGetNameListResponseDecoder.Decode(response, invokeId); LastDiscoveryResponseHex = last.ResponseHexPreview; + observation.Complete(last.IsSuccess); } catch (OperationCanceledException) { From e1436aa8d942061f4fa4dbf1cbf19f4541efa4ea Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 17 Sep 2026 08:26:44 +0700 Subject: [PATCH 36/66] test(discovery): lock KPI convergence invariants --- .../Mms/MmsSmartDiscoveryKpiTests.cs | 87 +++++++++++++++++++ 1 file changed, 87 insertions(+) create mode 100644 tests/AR.Iec61850.Tests/Mms/MmsSmartDiscoveryKpiTests.cs diff --git a/tests/AR.Iec61850.Tests/Mms/MmsSmartDiscoveryKpiTests.cs b/tests/AR.Iec61850.Tests/Mms/MmsSmartDiscoveryKpiTests.cs new file mode 100644 index 00000000..6593d582 --- /dev/null +++ b/tests/AR.Iec61850.Tests/Mms/MmsSmartDiscoveryKpiTests.cs @@ -0,0 +1,87 @@ +using AR.Iec61850.Mms; + +namespace AR.Iec61850.Tests.Mms; + +public sealed class MmsSmartDiscoveryKpiTests +{ + [Fact] + public void EquivalentEvidence_DifferentCompletionOrder_ProducesSameSignature() + { + var first = new MmsSmartDiscoveryKpiRecorder(1); + using (var a = first.BeginRequest("structure", "GetNameList", "NamedVariable|LD0|")) + using (var b = first.BeginRequest("type-enrichment", "GetVariableAccessAttributes", "LD0/XCBR1")) + { + b.Complete(true); + a.Complete(true); + } + first.UpdateCompleteness(32, 119, 3465, 4925, 2, 2, 58, 32, 16, 16); + + var second = new MmsSmartDiscoveryKpiRecorder(2); + using (var b = second.BeginRequest("type-enrichment", "GetVariableAccessAttributes", "LD0/XCBR1")) + using (var a = second.BeginRequest("structure", "GetNameList", "NamedVariable|LD0|")) + { + a.Complete(true); + b.Complete(true); + } + second.UpdateCompleteness(32, 119, 3465, 4925, 2, 2, 58, 32, 16, 16); + + Assert.Equal(first.Snapshot().DeterministicSignature, second.Snapshot().DeterministicSignature); + } + + [Fact] + public void RepeatedSemanticRequest_IsCountedAsDuplicate() + { + var recorder = new MmsSmartDiscoveryKpiRecorder(1); + + using (var first = recorder.BeginRequest("structure", "GetNameList", "NamedVariable|LD0|")) + first.Complete(true); + using (var second = recorder.BeginRequest("structure", "GetNameList", "NamedVariable|LD0|")) + second.Complete(true); + + var snapshot = recorder.Snapshot(); + var phase = Assert.Single(snapshot.Phases); + + Assert.Equal(2, snapshot.TotalRequests); + Assert.Equal(2, snapshot.SuccessfulRequests); + Assert.Equal(1, snapshot.DuplicateRequests); + Assert.Equal(1, phase.DuplicateRequests); + } + + [Fact] + public void PeakOutstanding_TracksConcurrentRequestsWithoutChangingRequestCount() + { + var recorder = new MmsSmartDiscoveryKpiRecorder(1); + + using var first = recorder.BeginRequest("type-enrichment", "GetVariableAccessAttributes", "LD0/XCBR1"); + using var second = recorder.BeginRequest("type-enrichment", "GetVariableAccessAttributes", "LD0/XSWI1"); + using var third = recorder.BeginRequest("type-enrichment", "GetVariableAccessAttributes", "LD0/MMXU1"); + + third.Complete(true); + first.Complete(true); + second.Complete(false); + + var snapshot = recorder.Snapshot(); + + Assert.Equal(3, snapshot.TotalRequests); + Assert.Equal(3, snapshot.PeakOutstandingRequests); + Assert.Equal(2, snapshot.SuccessfulRequests); + Assert.Equal(1, snapshot.FailedRequests); + Assert.Equal(0, snapshot.DuplicateRequests); + } + + [Fact] + public void ModelRefresh_ChangesCompletenessAndSignatureWithoutAddingRequests() + { + var recorder = new MmsSmartDiscoveryKpiRecorder(1); + recorder.UpdateCompleteness(32, 119, 3465, 3465, 2, 2, 58, 32, 16, 16); + var before = recorder.Snapshot(); + + recorder.UpdateModelCompleteness(32, 119, 4925); + var after = recorder.Snapshot(); + + Assert.Equal(0, before.TotalRequests); + Assert.Equal(0, after.TotalRequests); + Assert.Equal(4925, after.FcPointCount); + Assert.NotEqual(before.DeterministicSignature, after.DeterministicSignature); + } +} From ca770bc9f52896a5d2d236d4d241495d25f1d408 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 17 Sep 2026 08:29:22 +0700 Subject: [PATCH 37/66] fix(discovery): expose KPI wire-accounting coverage --- src/AR.Iec61850/Mms/MmsSmartDiscoveryKpi.cs | 30 ++++++++++++++++++--- 1 file changed, 27 insertions(+), 3 deletions(-) diff --git a/src/AR.Iec61850/Mms/MmsSmartDiscoveryKpi.cs b/src/AR.Iec61850/Mms/MmsSmartDiscoveryKpi.cs index a882e61f..1584ecae 100644 --- a/src/AR.Iec61850/Mms/MmsSmartDiscoveryKpi.cs +++ b/src/AR.Iec61850/Mms/MmsSmartDiscoveryKpi.cs @@ -32,6 +32,8 @@ public sealed class MmsSmartDiscoveryKpiSnapshot public int FailedRequests { get; init; } public int DuplicateRequests { get; init; } public int PeakOutstandingRequests { get; init; } + public bool WireAccountingComplete { get; init; } = true; + public IReadOnlyList AccountingNotes { get; init; } = Array.Empty(); public int LogicalDeviceCount { get; init; } public int LogicalNodeCount { get; init; } public int RawVariableCount { get; init; } @@ -48,9 +50,9 @@ public sealed class MmsSmartDiscoveryKpiSnapshot public string Summary => $"Smart discovery KPI: generation={Generation}, requests={TotalRequests}, success={SuccessfulRequests}, " + $"failed={FailedRequests}, duplicates={DuplicateRequests}, peakOutstanding={PeakOutstandingRequests}, " + - $"elapsed={ElapsedMs:0.0} ms, LD={LogicalDeviceCount}, LN={LogicalNodeCount}, " + - $"raw={RawVariableCount}, FC-points={FcPointCount}, datasets={DataSetCount}, " + - $"datasetDirectories={DataSetDirectoryCount}, datasetMembers={DataSetMemberCount}, " + + $"wireAccounting={(WireAccountingComplete ? "complete" : "partial")}, elapsed={ElapsedMs:0.0} ms, " + + $"LD={LogicalDeviceCount}, LN={LogicalNodeCount}, raw={RawVariableCount}, FC-points={FcPointCount}, " + + $"datasets={DataSetCount}, datasetDirectories={DataSetDirectoryCount}, datasetMembers={DataSetMemberCount}, " + $"RCB={ReportControlCount}, signature={DeterministicSignature}."; } @@ -80,6 +82,9 @@ private MmsSmartDiscoveryRequestObservation ObserveSmartDiscoveryRequest( => Volatile.Read(ref _smartDiscoveryKpiRecorder)?.BeginRequest(phase, service, logicalKey) ?? MmsSmartDiscoveryRequestObservation.Noop; + private void MarkSmartDiscoveryKpiAccountingPartial(string note) + => Volatile.Read(ref _smartDiscoveryKpiRecorder)?.MarkAccountingPartial(note); + private void UpdateSmartDiscoveryCompleteness( MmsDiscoverySnapshot snapshot, MmsIedModelDirectory directory, @@ -152,12 +157,14 @@ internal sealed class MmsSmartDiscoveryKpiRecorder private readonly long _startedTimestamp = Stopwatch.GetTimestamp(); private readonly Dictionary _requestStats = new(StringComparer.Ordinal); private readonly Dictionary _phaseStats = new(StringComparer.OrdinalIgnoreCase); + private readonly HashSet _accountingNotes = new(StringComparer.Ordinal); private int _totalRequests; private int _successfulRequests; private int _failedRequests; private int _duplicateRequests; private int _outstandingRequests; private int _peakOutstandingRequests; + private bool _wireAccountingComplete = true; private int _logicalDeviceCount; private int _logicalNodeCount; private int _rawVariableCount; @@ -214,6 +221,16 @@ public MmsSmartDiscoveryRequestObservation BeginRequest(string phase, string ser return new MmsSmartDiscoveryRequestObservation(this, normalizedPhase, descriptor, Stopwatch.GetTimestamp()); } + public void MarkAccountingPartial(string note) + { + lock (_gate) + { + _wireAccountingComplete = false; + var normalized = Normalize(note, "unspecified unobserved request path"); + _accountingNotes.Add(normalized); + } + } + public void UpdateCompleteness( int logicalDeviceCount, int logicalNodeCount, @@ -280,6 +297,8 @@ public MmsSmartDiscoveryKpiSnapshot Snapshot() FailedRequests = _failedRequests, DuplicateRequests = _duplicateRequests, PeakOutstandingRequests = _peakOutstandingRequests, + WireAccountingComplete = _wireAccountingComplete, + AccountingNotes = _accountingNotes.OrderBy(note => note, StringComparer.Ordinal).ToArray(), LogicalDeviceCount = _logicalDeviceCount, LogicalNodeCount = _logicalNodeCount, RawVariableCount = _rawVariableCount, @@ -339,6 +358,11 @@ private string BuildDeterministicSignature() .Append('\n'); } + builder.Append("accounting|complete=").Append(_wireAccountingComplete); + foreach (var note in _accountingNotes.OrderBy(note => note, StringComparer.Ordinal)) + builder.Append("|note=").Append(note); + builder.Append('\n'); + builder.Append("model|ld=").Append(_logicalDeviceCount) .Append("|ln=").Append(_logicalNodeCount) .Append("|raw=").Append(_rawVariableCount) From 9d6e3c13d61e90e2fa56ef5a453daf8e944983f8 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 17 Sep 2026 08:30:36 +0700 Subject: [PATCH 38/66] fix(discovery): mark unobserved report-enrichment wire accounting --- src/AR.Iec61850/Mms/MmsClientSession.SmartDiscovery.cs | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/AR.Iec61850/Mms/MmsClientSession.SmartDiscovery.cs b/src/AR.Iec61850/Mms/MmsClientSession.SmartDiscovery.cs index 5be42685..b9d02ad7 100644 --- a/src/AR.Iec61850/Mms/MmsClientSession.SmartDiscovery.cs +++ b/src/AR.Iec61850/Mms/MmsClientSession.SmartDiscovery.cs @@ -216,11 +216,19 @@ await EnrichReportInventoryAsync( ? "report enrichment=partial" : "report enrichment=completed"; + if (options.ProbeReportAttributes && + options.MaxReportAttributeProbes > 0 && + inventory.ReportControls.Count > 0) + { + MarkSmartDiscoveryKpiAccountingPartial( + "report-enrichment confirmed Reads are not yet individually observed by the smart KPI recorder"); + } + UpdateSmartDiscoveryCompleteness(snapshot, iedDirectory, inventory, dataSetDirectories); var kpi = LastSmartDiscoveryKpi; var kpiSummary = kpi is null ? "kpi=unavailable" - : $"kpi requests={kpi.TotalRequests}, duplicates={kpi.DuplicateRequests}, peak={kpi.PeakOutstandingRequests}, signature={kpi.DeterministicSignature}"; + : $"kpi requests={kpi.TotalRequests}, duplicates={kpi.DuplicateRequests}, peak={kpi.PeakOutstandingRequests}, wireAccounting={(kpi.WireAccountingComplete ? "complete" : "partial")}, signature={kpi.DeterministicSignature}"; LastDiscoveryAttemptSummary = $"Smart discovery: {domainStatus}, chains={chains.Length}, incompleteChains={incompleteChains}, " + From d389082a28c47d34ce5c0b0d8954007f266daeb2 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 17 Sep 2026 08:30:55 +0700 Subject: [PATCH 39/66] test(discovery): lock KPI accounting coverage semantics --- .../Mms/MmsSmartDiscoveryKpiTests.cs | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/tests/AR.Iec61850.Tests/Mms/MmsSmartDiscoveryKpiTests.cs b/tests/AR.Iec61850.Tests/Mms/MmsSmartDiscoveryKpiTests.cs index 6593d582..00a8157a 100644 --- a/tests/AR.Iec61850.Tests/Mms/MmsSmartDiscoveryKpiTests.cs +++ b/tests/AR.Iec61850.Tests/Mms/MmsSmartDiscoveryKpiTests.cs @@ -84,4 +84,21 @@ public void ModelRefresh_ChangesCompletenessAndSignatureWithoutAddingRequests() Assert.Equal(4925, after.FcPointCount); Assert.NotEqual(before.DeterministicSignature, after.DeterministicSignature); } + + [Fact] + public void PartialWireAccounting_IsExplicitAndDeterministic() + { + var first = new MmsSmartDiscoveryKpiRecorder(1); + first.MarkAccountingPartial("Report-enrichment confirmed Reads are not individually observed"); + + var second = new MmsSmartDiscoveryKpiRecorder(2); + second.MarkAccountingPartial("Report-enrichment confirmed Reads are not individually observed"); + + var firstSnapshot = first.Snapshot(); + var secondSnapshot = second.Snapshot(); + + Assert.False(firstSnapshot.WireAccountingComplete); + Assert.Single(firstSnapshot.AccountingNotes); + Assert.Equal(firstSnapshot.DeterministicSignature, secondSnapshot.DeterministicSignature); + } } From effa1736da15ec5df728684631aefa14e6d7aa0a Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 17 Sep 2026 08:31:37 +0700 Subject: [PATCH 40/66] docs(discovery): define P0-4 KPI convergence contract --- docs/SMART_DISCOVERY_KPI.md | 140 ++++++++++++++++++++++++++++++++++++ 1 file changed, 140 insertions(+) create mode 100644 docs/SMART_DISCOVERY_KPI.md diff --git a/docs/SMART_DISCOVERY_KPI.md b/docs/SMART_DISCOVERY_KPI.md new file mode 100644 index 00000000..0ed7da86 --- /dev/null +++ b/docs/SMART_DISCOVERY_KPI.md @@ -0,0 +1,140 @@ +# Smart Discovery KPI / Capture Convergence + +Status: **implemented + unit tested; live capture convergence not yet re-validated after this patch**. + +This document defines the P0-4 observability contract for smart IEC 61850 discovery. The KPI layer is diagnostic evidence only. It does not create a second IEC 61850 model, does not alter canonical semantics, and does not add MMS traffic. + +## Architecture + +```text +existing smart discovery requests + | + +-- GetNameList + +-- GetNamedVariableListAttributes + +-- GetVariableAccessAttributes + `-- bounded FC-root Read + | + v +existing confirmed-service execution + | + +------------------------+ + | | + v v +canonical discovery/model zero-traffic KPI recorder + counts / latency / duplicate keys + peak outstanding / completeness + deterministic signature +``` + +The canonical IED model remains the only semantic source of truth. KPI fields are an evidence overlay and are never used to manufacture LD/LN/DO/DA, DataSet, RCB, value, type, or SCL semantics. + +## Zero-traffic invariant + +KPI collection must not issue any additional: + +- `GetNameList`; +- `GetVariableAccessAttributes`; +- `Read`; +- `GetNamedVariableListAttributes`; +- association request; +- report/control request; +- transport probe. + +Observations are opened only around confirmed requests that the smart path already intends to send. + +## Observed phases + +The optimized default path records these wire request classes: + +| Phase | MMS service | Logical duplicate key | +|---|---|---| +| `structure` | `GetNameList` | object class + domain/VMD + continuation token | +| `dataset-directory` | `GetNamedVariableListAttributes` | exact DataSet reference | +| `type-enrichment` | `GetVariableAccessAttributes` | exact domain/item reference | +| `initial-read` | `Read` | ordered FC-root reference batch | + +A repeated normalized logical key in one generation increments `DuplicateRequests`. Legitimate GetNameList continuation pages are distinct because the continuation token is part of the key. + +## Snapshot contract + +`MmsClientSession.LastSmartDiscoveryKpi` exposes a point-in-time `MmsSmartDiscoveryKpiSnapshot` containing: + +- total / successful / failed observed confirmed requests; +- duplicate request count; +- peak outstanding request count; +- per-phase request count and latency statistics; +- LD, LN, raw-variable and FC-point completeness; +- DataSet / DataSet-directory / ordered member completeness; +- RCB / BRCB / URCB counts; +- `WireAccountingComplete` plus explicit `AccountingNotes`; +- deterministic signature. + +`RefreshSmartDiscoveryModelKpi(directory)` can refresh LD/LN/FC-point completeness after later semantic materialization. It is local bookkeeping and performs no network I/O. + +## Deterministic signature + +The signature intentionally excludes: + +- wall-clock timestamps; +- measured request latency; +- worker completion order; +- invoke ID allocation order. + +It includes normalized request identity/attempt outcomes, accounting coverage, and semantic completeness counts. Equivalent evidence should therefore produce the same signature even when concurrent workers finish in a different order. + +This signature is a convergence diagnostic, not an IEC 61850 semantic fingerprint and not an SCL identity. + +## Accounting coverage + +The default optimized smart-discovery path keeps report attribute probing off the structural critical path. For that path, all currently instrumented smart request loops can report complete wire accounting. + +If `ProbeReportAttributes=true` and report controls are eligible for probing, the current legacy report-enrichment Read fallback path is not yet observed at individual wire-attempt granularity. The snapshot therefore sets: + +```text +WireAccountingComplete = false +``` + +and adds an explicit accounting note. This prevents an undercount from being presented as complete evidence. + +## Current controlled convergence target + +For the current controlled regression device/session, the project-level target supplied to P0 is: + +```text +LD 32 +LN 119 +semantic leaves 4,925 +DataSet 2 +ordered FCDA 58 +logical ReportControl 32 +``` + +These values are capture-specific regression evidence, not IEC 61850 limits and must never be hard-coded into protocol behavior. + +P0-4 live acceptance requires a new controlled run to demonstrate, for the same evidence set: + +1. expected canonical completeness is retained; +2. `DuplicateRequests == 0` unless a documented retry/fallback is expected; +3. `WireAccountingComplete == true` for the optimized default path; +4. repeated runs yield the same deterministic signature when semantic evidence and outcomes are unchanged; +5. request count and per-phase latency are recorded without additional MMS traffic; +6. DataSet member order and RCB semantics remain unchanged. + +## What is validated now + +- **Implemented:** zero-traffic KPI recorder and smart call-site observations. +- **Unit tested:** deterministic signature independent of completion order; duplicate detection; peak outstanding count; model-completeness refresh; explicit partial accounting. +- **CI validated:** build/test/source-verification must pass on the exact PR head before this phase is called code-complete. +- **Not yet laboratory re-validated:** final request counts, latency distribution, duplicate count, and canonical completeness against a fresh controlled packet capture after this patch. + +## Next phase: P0-5 Request Budget & Adaptive Convergence + +P0-5 may consume P0-4 evidence to reduce redundant requests and tune bounded concurrency. It must not optimize from guesses. Any request removal or concurrency change must preserve canonical completeness, DataSet ordering, report/control safety, deterministic publication order, and negotiated `maxOutstandingCalling` limits. + +P0-5 candidate gates: + +- establish per-phase request budget from controlled repeat runs; +- identify only proven duplicate/redundant semantic requests; +- adapt window conservatively from negotiated peer limits and observed latency/failure evidence; +- compare before/after request count and wall time with identical canonical signature/completeness; +- fall back to the conservative path when evidence is insufficient. From 9db6b45469bd00cde55ace4bf6c0a5012c7c1604 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 17 Sep 2026 08:31:58 +0700 Subject: [PATCH 41/66] docs(changelog): record smart discovery KPI evidence --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9ddb57cb..16cb5c5e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ All notable public changes to ARIEC61850 are recorded here. The project is still ### Added +- Added zero-traffic smart-discovery KPI evidence across structural `GetNameList`, DataSet directory, hierarchy GVA, and bounded FC-root initial Read phases, including per-phase latency, duplicate semantic-request detection, peak outstanding requests, canonical completeness counters, deterministic repeat-run signatures, and explicit partial wire-accounting status for unobserved enrichment paths. - Added live-authoritative SCL ReportControl family reconciliation for static reporting: indexed declarative controls are matched only to concrete live MMS RCB instances in the same domain/logical-node/report-FC with decimal instance suffixes, while non-indexed controls remain exact-name only and no runtime RCB name is synthesized. - Added an SCL-aware static report planner that scopes the existing safe RCB selector to the reconciled live family, allowing a busy first instance to fall through to another proven-free instance without escaping to an unrelated control block. - Added registered-monitor initial GI bootstrap for persistent reporting: the monitor is installed before the one-shot GI request, initial mapped DataSet reports are returned explicitly, and GI is attempted only when the live RCB attribute inventory proves the field exists. From f1e95773c09c94f57a277308a7bca528b9a1f459 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 17 Sep 2026 08:50:19 +0700 Subject: [PATCH 42/66] perf(discovery): pipeline DataSet directory reads --- ...msClientSession.SmartDataSetDirectories.cs | 67 +++++++++++++++++++ 1 file changed, 67 insertions(+) create mode 100644 src/AR.Iec61850/Mms/MmsClientSession.SmartDataSetDirectories.cs diff --git a/src/AR.Iec61850/Mms/MmsClientSession.SmartDataSetDirectories.cs b/src/AR.Iec61850/Mms/MmsClientSession.SmartDataSetDirectories.cs new file mode 100644 index 00000000..55a0feae --- /dev/null +++ b/src/AR.Iec61850/Mms/MmsClientSession.SmartDataSetDirectories.cs @@ -0,0 +1,67 @@ +namespace AR.Iec61850.Mms; + +public sealed partial class MmsClientSession +{ + /// + /// Reads independent DataSet directories with the same association-aware bounded + /// request window used by smart structural discovery. Each individual request is + /// still executed through the existing observed directory reader, so P0-4 KPI + /// accounting remains authoritative and no extra MMS request is introduced. + /// Results are published in the original DataSet order, independent of worker + /// completion order. + /// + private async Task> GetObservedSmartDataSetDirectoriesPipelinedAsync( + IReadOnlyList dataSetReferences, + MmsIedModelDirectory directory, + int maxConcurrency, + CancellationToken cancellationToken) + { + if (dataSetReferences.Count == 0) + return Array.Empty(); + + var results = new MmsDataSetDirectoryResult?[dataSetReferences.Count]; + var nextIndex = -1; + var workerCount = Math.Min(Math.Max(1, maxConcurrency), dataSetReferences.Count); + var workers = new Task[workerCount]; + + for (var worker = 0; worker < workerCount; worker++) + workers[worker] = WorkerAsync(); + + await Task.WhenAll(workers).ConfigureAwait(false); + + // Keep publication deterministic: request completion may be concurrent, but + // consumers see the exact canonical DataSet reference order supplied by the + // discovery inventory. + return results + .Where(result => result is not null) + .Select(result => result!) + .ToArray(); + + async Task WorkerAsync() + { + while (true) + { + cancellationToken.ThrowIfCancellationRequested(); + if (!IsMmsInitiated) + return; + + var index = Interlocked.Increment(ref nextIndex); + if (index >= dataSetReferences.Count) + return; + + var singleton = new[] { dataSetReferences[index] }; + var observed = await GetObservedSmartDataSetDirectoriesAsync( + singleton, + directory, + cancellationToken) + .ConfigureAwait(false); + + if (observed.Count > 0) + results[index] = observed[0]; + + if (!IsMmsInitiated) + return; + } + } + } +} From 1d5919c1f7e0a7f8a3cee99f426ab5c724d8db3f Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 17 Sep 2026 08:51:03 +0700 Subject: [PATCH 43/66] perf(discovery): use bounded DataSet directory pipeline --- src/AR.Iec61850/Mms/MmsClientSession.SmartDiscovery.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/AR.Iec61850/Mms/MmsClientSession.SmartDiscovery.cs b/src/AR.Iec61850/Mms/MmsClientSession.SmartDiscovery.cs index b9d02ad7..7e356ab1 100644 --- a/src/AR.Iec61850/Mms/MmsClientSession.SmartDiscovery.cs +++ b/src/AR.Iec61850/Mms/MmsClientSession.SmartDiscovery.cs @@ -178,9 +178,10 @@ await EnrichReportInventoryAsync( { try { - dataSetDirectories = (await GetObservedSmartDataSetDirectoriesAsync( + dataSetDirectories = (await GetObservedSmartDataSetDirectoriesPipelinedAsync( dataSetReferences, iedDirectory, + effectiveWindow, cancellationToken) .ConfigureAwait(false)) .ToArray(); From 12d4f3d94eb23357119b801a4a0d81757d461951 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 17 Sep 2026 08:51:33 +0700 Subject: [PATCH 44/66] refactor(discovery): expose DataSet pipeline worker policy --- .../Mms/MmsSmartDataSetPipelinePolicy.cs | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 src/AR.Iec61850/Mms/MmsSmartDataSetPipelinePolicy.cs diff --git a/src/AR.Iec61850/Mms/MmsSmartDataSetPipelinePolicy.cs b/src/AR.Iec61850/Mms/MmsSmartDataSetPipelinePolicy.cs new file mode 100644 index 00000000..f3c55d9a --- /dev/null +++ b/src/AR.Iec61850/Mms/MmsSmartDataSetPipelinePolicy.cs @@ -0,0 +1,17 @@ +namespace AR.Iec61850.Mms; + +/// +/// Pure policy helpers for the smart DataSet-directory pipeline. Keeping the worker +/// budget calculation separate makes the negotiated-request-window invariant easy to +/// regression test without network traffic. +/// +public static class MmsSmartDataSetPipelinePolicy +{ + public static int ResolveWorkerCount(int dataSetCount, int maxConcurrency) + { + if (dataSetCount <= 0) + return 0; + + return Math.Min(dataSetCount, Math.Max(1, maxConcurrency)); + } +} From f6d5673099f81a1cd8babfe94eb6cc7e41cd8c1f Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 17 Sep 2026 08:51:50 +0700 Subject: [PATCH 45/66] refactor(discovery): apply DataSet worker budget policy --- .../Mms/MmsClientSession.SmartDataSetDirectories.cs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/AR.Iec61850/Mms/MmsClientSession.SmartDataSetDirectories.cs b/src/AR.Iec61850/Mms/MmsClientSession.SmartDataSetDirectories.cs index 55a0feae..2b613704 100644 --- a/src/AR.Iec61850/Mms/MmsClientSession.SmartDataSetDirectories.cs +++ b/src/AR.Iec61850/Mms/MmsClientSession.SmartDataSetDirectories.cs @@ -16,12 +16,14 @@ private async Task> GetObservedSmartDat int maxConcurrency, CancellationToken cancellationToken) { - if (dataSetReferences.Count == 0) + var workerCount = MmsSmartDataSetPipelinePolicy.ResolveWorkerCount( + dataSetReferences.Count, + maxConcurrency); + if (workerCount == 0) return Array.Empty(); var results = new MmsDataSetDirectoryResult?[dataSetReferences.Count]; var nextIndex = -1; - var workerCount = Math.Min(Math.Max(1, maxConcurrency), dataSetReferences.Count); var workers = new Task[workerCount]; for (var worker = 0; worker < workerCount; worker++) From a483423b4459e91dcc874d01951a600d46b5df32 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 17 Sep 2026 08:51:55 +0700 Subject: [PATCH 46/66] test(discovery): lock DataSet pipeline request budget --- .../Mms/MmsSmartDataSetPipelinePolicyTests.cs | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 tests/AR.Iec61850.Tests/Mms/MmsSmartDataSetPipelinePolicyTests.cs diff --git a/tests/AR.Iec61850.Tests/Mms/MmsSmartDataSetPipelinePolicyTests.cs b/tests/AR.Iec61850.Tests/Mms/MmsSmartDataSetPipelinePolicyTests.cs new file mode 100644 index 00000000..00f071ac --- /dev/null +++ b/tests/AR.Iec61850.Tests/Mms/MmsSmartDataSetPipelinePolicyTests.cs @@ -0,0 +1,23 @@ +using AR.Iec61850.Mms; + +namespace AR.Iec61850.Tests.Mms; + +public sealed class MmsSmartDataSetPipelinePolicyTests +{ + [Theory] + [InlineData(0, 8, 0)] + [InlineData(1, 8, 1)] + [InlineData(2, 8, 2)] + [InlineData(8, 4, 4)] + [InlineData(8, 0, 1)] + [InlineData(32, 10, 10)] + public void ResolveWorkerCount_RespectsAssociationWindowAndAvailableWork( + int dataSetCount, + int maxConcurrency, + int expected) + { + Assert.Equal( + expected, + MmsSmartDataSetPipelinePolicy.ResolveWorkerCount(dataSetCount, maxConcurrency)); + } +} From f7831b414c6e48786b9333e72d3204d4b0c181ff Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 17 Sep 2026 09:18:16 +0700 Subject: [PATCH 47/66] perf(discovery): plan live-only nonrepeating GVA budget --- .../Mms/MmsSmartDiscoveryPolicy.cs | 86 +++++++++++++++++++ 1 file changed, 86 insertions(+) diff --git a/src/AR.Iec61850/Mms/MmsSmartDiscoveryPolicy.cs b/src/AR.Iec61850/Mms/MmsSmartDiscoveryPolicy.cs index 0cad29a7..0e6ddaae 100644 --- a/src/AR.Iec61850/Mms/MmsSmartDiscoveryPolicy.cs +++ b/src/AR.Iec61850/Mms/MmsSmartDiscoveryPolicy.cs @@ -79,6 +79,49 @@ public static string[] OrderDomainsForScheduling( internal static class MmsSmartTypeProbePolicy { + private const char CompositeKeySeparator = '\u001F'; + + /// + /// Intersects supplied LN-root candidates with the logical nodes proven by the + /// current live directory. Hints can affect order at higher layers, but a GVA must + /// never be sent merely because a stale SCL/caller candidate names a non-live LN. + /// Returned references use canonical live directory spelling and deterministic + /// LD/LN order. + /// + public static MmsObjectReference[] SelectLiveLogicalNodeRoots( + MmsIedModelDirectory directory, + IEnumerable candidates) + { + ArgumentNullException.ThrowIfNull(directory); + ArgumentNullException.ThrowIfNull(candidates); + + var requested = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var reference in candidates) + { + if (string.IsNullOrWhiteSpace(reference.Domain) || + string.IsNullOrWhiteSpace(reference.Item) || + reference.Item.Contains('$', StringComparison.Ordinal)) + { + continue; + } + + requested.Add(BuildReferenceKey(reference.Domain.Trim(), reference.Item.Trim())); + } + + if (requested.Count == 0) + return Array.Empty(); + + return directory.LogicalDevices.Values + .OrderBy(device => device.Name, StringComparer.OrdinalIgnoreCase) + .ThenBy(device => device.Name, StringComparer.Ordinal) + .SelectMany(device => device.LogicalNodes.Values + .OrderBy(node => node.Name, StringComparer.OrdinalIgnoreCase) + .ThenBy(node => node.Name, StringComparer.Ordinal) + .Where(node => requested.Contains(BuildReferenceKey(device.Name, node.Name))) + .Select(node => new MmsObjectReference(device.Name, node.Name, string.Empty))) + .ToArray(); + } + public static MmsObjectReference BuildDataObjectRoot(MmsFcResolvedPoint point) { ArgumentNullException.ThrowIfNull(point); @@ -90,6 +133,46 @@ public static MmsObjectReference BuildDataObjectRoot(MmsFcResolvedPoint point) return new MmsObjectReference(point.Domain, item, point.FunctionalConstraint); } + /// + /// Builds exact fallback probes without ever reissuing an exact GVA reference that + /// was already attempted earlier in the same hierarchy ladder. This matters for + /// flat inventories containing LN$FC$DO roots: after a DO-root GVA fails or is + /// shallow, treating that same root as a leaf fallback would otherwise send the + /// identical request twice with no new evidence boundary. + /// + public static MmsObjectReference[] BuildUnprobedExactFallbacks( + IEnumerable unresolvedPoints, + IEnumerable alreadyProbed) + { + ArgumentNullException.ThrowIfNull(unresolvedPoints); + ArgumentNullException.ThrowIfNull(alreadyProbed); + + var seen = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var reference in alreadyProbed) + { + if (string.IsNullOrWhiteSpace(reference.Domain) || string.IsNullOrWhiteSpace(reference.Item)) + continue; + seen.Add(BuildReferenceKey(reference.Domain.Trim(), reference.Item.Trim())); + } + + var fallback = new List(); + foreach (var point in unresolvedPoints + .Where(point => !string.IsNullOrWhiteSpace(point.Domain) && + !string.IsNullOrWhiteSpace(point.MmsItemName)) + .OrderBy(point => point.Domain, StringComparer.OrdinalIgnoreCase) + .ThenBy(point => point.MmsItemName, StringComparer.OrdinalIgnoreCase)) + { + var reference = point.ToObjectReference(); + var key = BuildReferenceKey(reference.Domain, reference.Item); + if (!seen.Add(key)) + continue; + + fallback.Add(reference); + } + + return fallback.ToArray(); + } + /// /// Returns true only when the supplied GVA result can prove the requested MMS /// item through its TypeSpecification hierarchy. A successful but shallow result @@ -128,6 +211,9 @@ public static bool Covers( return true; } + private static string BuildReferenceKey(string domain, string item) + => string.Concat(domain ?? string.Empty, CompositeKeySeparator, item ?? string.Empty); + private static string[] Split(string value) => (value ?? string.Empty).Split( '$', From d04d3691ab96d1707e80b9b7a96bce606c182d55 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 17 Sep 2026 09:19:00 +0700 Subject: [PATCH 48/66] perf(discovery): converge hierarchy GVA request budget --- ...ntSession.SmartVariableAccessAttributes.cs | 153 ++++++++++++++++-- 1 file changed, 137 insertions(+), 16 deletions(-) diff --git a/src/AR.Iec61850/Mms/MmsClientSession.SmartVariableAccessAttributes.cs b/src/AR.Iec61850/Mms/MmsClientSession.SmartVariableAccessAttributes.cs index 5255d2d2..316e872a 100644 --- a/src/AR.Iec61850/Mms/MmsClientSession.SmartVariableAccessAttributes.cs +++ b/src/AR.Iec61850/Mms/MmsClientSession.SmartVariableAccessAttributes.cs @@ -1,12 +1,48 @@ namespace AR.Iec61850.Mms; +/// +/// Diagnostic request-budget evidence for one smart hierarchy GVA pass. Counts describe +/// the bounded probe ladder; they never influence canonical model semantics. +/// +public sealed class MmsSmartTypeProbeBudgetSnapshot +{ + public int DirectoryPoints { get; init; } + public int SuppliedLogicalNodeCandidates { get; init; } + public int SuppressedNonLiveLogicalNodeCandidates { get; init; } + public int LogicalNodeRequests { get; init; } + public int PointsCoveredByLogicalNode { get; init; } + public int DataObjectRequests { get; init; } + public int PointsCoveredByDataObject { get; init; } + public int ExactLeafRequests { get; init; } + public int SuppressedExactRepeatRequests { get; init; } + public int PointsCoveredByExactLeaf { get; init; } + public int RemainingUnresolvedPoints { get; init; } + public int TotalPlannedRequests => LogicalNodeRequests + DataObjectRequests + ExactLeafRequests; + + public string Summary => + $"Smart type budget: points={DirectoryPoints}, LN={LogicalNodeRequests}, " + + $"DO={DataObjectRequests}, leaf={ExactLeafRequests}, total={TotalPlannedRequests}, " + + $"covered(LN/DO/leaf)={PointsCoveredByLogicalNode}/{PointsCoveredByDataObject}/{PointsCoveredByExactLeaf}, " + + $"suppressed(nonLive/repeat)={SuppressedNonLiveLogicalNodeCandidates}/{SuppressedExactRepeatRequests}, " + + $"unresolved={RemainingUnresolvedPoints}."; +} + public sealed partial class MmsClientSession { + private MmsSmartTypeProbeBudgetSnapshot? _lastSmartTypeProbeBudget; + + /// + /// Most recent hierarchy GVA budget. This is local diagnostic evidence only and + /// does not issue requests or alter the discovered IEC 61850 model. + /// + public MmsSmartTypeProbeBudgetSnapshot? LastSmartTypeProbeBudget + => Volatile.Read(ref _lastSmartTypeProbeBudget); + /// /// Convenience entry point for live-only callers. Canonical discovery code should /// prefer the overload that supplies logical-node root candidates from its semantic /// probe planner. The fallback ladder is LN root -> unresolved DO root -> unresolved - /// leaf, so normal structured IEDs need only roughly one GVA per logical node. + /// exact leaf, with exact-reference repeat suppression across tiers. /// public Task> GetVariableAccessAttributesSmartAsync( MmsIedModelDirectory directory, @@ -30,8 +66,9 @@ public Task> GetVariableAccessA /// /// Executes a bounded, coverage-aware type discovery ladder. A successful parent - /// TypeSpecification suppresses all descendant probes it can actually prove; only - /// unresolved branches descend to DO roots and finally exact leaves. + /// TypeSpecification suppresses every descendant probe it can prove. Only unresolved + /// branches descend to distinct DO roots and finally exact leaves. An exact GVA + /// reference is never reissued inside one pass merely because a shallower tier failed. /// public async Task> GetVariableAccessAttributesSmartAsync( MmsIedModelDirectory directory, @@ -53,20 +90,34 @@ public async Task> GetVariableA .ThenBy(point => point.MmsItemName, StringComparer.OrdinalIgnoreCase) .ToArray(); if (points.Length == 0) + { + PublishSmartTypeProbeBudget(new MmsSmartTypeProbeBudgetSnapshot()); return Array.Empty(); + } - var logicalNodeRoots = logicalNodeRootCandidates + var suppliedCandidates = logicalNodeRootCandidates .Where(reference => !string.IsNullOrWhiteSpace(reference.Domain) && - !string.IsNullOrWhiteSpace(reference.Item)) + !string.IsNullOrWhiteSpace(reference.Item) && + !reference.Item.Contains('$', StringComparison.Ordinal)) .Distinct(MmsObjectReferenceKeyComparer.Instance) - .OrderBy(reference => reference.Domain, StringComparer.OrdinalIgnoreCase) - .ThenBy(reference => reference.Item, StringComparer.OrdinalIgnoreCase) .ToArray(); + var logicalNodeRoots = MmsSmartTypeProbePolicy.SelectLiveLogicalNodeRoots(directory, suppliedCandidates); + var suppressedNonLiveRoots = Math.Max(0, suppliedCandidates.Length - logicalNodeRoots.Length); if (logicalNodeRoots.Length == 0) + { + PublishSmartTypeProbeBudget(new MmsSmartTypeProbeBudgetSnapshot + { + DirectoryPoints = points.Length, + SuppliedLogicalNodeCandidates = suppliedCandidates.Length, + SuppressedNonLiveLogicalNodeCandidates = suppressedNonLiveRoots, + RemainingUnresolvedPoints = points.Length + }); return Array.Empty(); + } var window = ResolveSmartDiscoveryWindow(options); var results = new List(logicalNodeRoots.Length); + var probedReferences = new List(logicalNodeRoots); var logicalNodeResults = await RunVariableAttributeBatchAsync( logicalNodeRoots, @@ -92,8 +143,20 @@ public async Task> GetVariableA unresolvedAfterLogicalNode.Add(point); } + var coveredByLogicalNode = points.Length - unresolvedAfterLogicalNode.Count; if (unresolvedAfterLogicalNode.Count == 0 || !IsMmsInitiated) + { + PublishSmartTypeProbeBudget(new MmsSmartTypeProbeBudgetSnapshot + { + DirectoryPoints = points.Length, + SuppliedLogicalNodeCandidates = suppliedCandidates.Length, + SuppressedNonLiveLogicalNodeCandidates = suppressedNonLiveRoots, + LogicalNodeRequests = logicalNodeRoots.Length, + PointsCoveredByLogicalNode = coveredByLogicalNode, + RemainingUnresolvedPoints = unresolvedAfterLogicalNode.Count + }); return results; + } var dataObjectRoots = unresolvedAfterLogicalNode .Select(MmsSmartTypeProbePolicy.BuildDataObjectRoot) @@ -102,6 +165,7 @@ public async Task> GetVariableA .OrderBy(reference => reference.Domain, StringComparer.OrdinalIgnoreCase) .ThenBy(reference => reference.Item, StringComparer.OrdinalIgnoreCase) .ToArray(); + probedReferences.AddRange(dataObjectRoots); var dataObjectResults = await RunVariableAttributeBatchAsync( dataObjectRoots, @@ -114,7 +178,7 @@ public async Task> GetVariableA .GroupBy(result => result.Reference, MmsObjectReferenceKeyComparer.Instance) .ToDictionary(group => group.Key, group => group.Last(), MmsObjectReferenceKeyComparer.Instance); - var leafFallbacks = new List(); + var unresolvedAfterDataObject = new List(); foreach (var point in unresolvedAfterLogicalNode) { var dataObjectRoot = MmsSmartTypeProbePolicy.BuildDataObjectRoot(point); @@ -124,27 +188,84 @@ public async Task> GetVariableA continue; } - leafFallbacks.Add(point.ToObjectReference()); + unresolvedAfterDataObject.Add(point); + } + + var coveredByDataObject = unresolvedAfterLogicalNode.Count - unresolvedAfterDataObject.Count; + if (unresolvedAfterDataObject.Count == 0 || !IsMmsInitiated) + { + PublishSmartTypeProbeBudget(new MmsSmartTypeProbeBudgetSnapshot + { + DirectoryPoints = points.Length, + SuppliedLogicalNodeCandidates = suppliedCandidates.Length, + SuppressedNonLiveLogicalNodeCandidates = suppressedNonLiveRoots, + LogicalNodeRequests = logicalNodeRoots.Length, + PointsCoveredByLogicalNode = coveredByLogicalNode, + DataObjectRequests = dataObjectRoots.Length, + PointsCoveredByDataObject = coveredByDataObject, + RemainingUnresolvedPoints = unresolvedAfterDataObject.Count + }); + return results; } - var distinctLeafFallbacks = leafFallbacks + var exactFallbackCandidates = unresolvedAfterDataObject + .Select(point => point.ToObjectReference()) .Distinct(MmsObjectReferenceKeyComparer.Instance) - .OrderBy(reference => reference.Domain, StringComparer.OrdinalIgnoreCase) - .ThenBy(reference => reference.Item, StringComparer.OrdinalIgnoreCase) .ToArray(); + var exactLeafFallbacks = MmsSmartTypeProbePolicy.BuildUnprobedExactFallbacks( + unresolvedAfterDataObject, + probedReferences); + var suppressedExactRepeats = Math.Max(0, exactFallbackCandidates.Length - exactLeafFallbacks.Length); - if (distinctLeafFallbacks.Length > 0 && IsMmsInitiated) + MmsVariableAccessAttributesResult[] exactLeafResults = Array.Empty(); + if (exactLeafFallbacks.Length > 0 && IsMmsInitiated) { - results.AddRange(await RunVariableAttributeBatchAsync( - distinctLeafFallbacks, + exactLeafResults = await RunVariableAttributeBatchAsync( + exactLeafFallbacks, window, cancellationToken) - .ConfigureAwait(false)); + .ConfigureAwait(false); + results.AddRange(exactLeafResults); + } + + var exactLeafIndex = exactLeafResults + .GroupBy(result => result.Reference, MmsObjectReferenceKeyComparer.Instance) + .ToDictionary(group => group.Key, group => group.Last(), MmsObjectReferenceKeyComparer.Instance); + var remainingUnresolved = 0; + foreach (var point in unresolvedAfterDataObject) + { + var exactReference = point.ToObjectReference(); + if (exactLeafIndex.TryGetValue(exactReference, out var exactResult) && + MmsSmartTypeProbePolicy.Covers(exactResult, point.MmsItemName)) + { + continue; + } + + remainingUnresolved++; } + var coveredByExactLeaf = unresolvedAfterDataObject.Count - remainingUnresolved; + PublishSmartTypeProbeBudget(new MmsSmartTypeProbeBudgetSnapshot + { + DirectoryPoints = points.Length, + SuppliedLogicalNodeCandidates = suppliedCandidates.Length, + SuppressedNonLiveLogicalNodeCandidates = suppressedNonLiveRoots, + LogicalNodeRequests = logicalNodeRoots.Length, + PointsCoveredByLogicalNode = coveredByLogicalNode, + DataObjectRequests = dataObjectRoots.Length, + PointsCoveredByDataObject = coveredByDataObject, + ExactLeafRequests = exactLeafFallbacks.Length, + SuppressedExactRepeatRequests = suppressedExactRepeats, + PointsCoveredByExactLeaf = coveredByExactLeaf, + RemainingUnresolvedPoints = remainingUnresolved + }); + return results; } + private void PublishSmartTypeProbeBudget(MmsSmartTypeProbeBudgetSnapshot snapshot) + => Volatile.Write(ref _lastSmartTypeProbeBudget, snapshot); + private async Task RunVariableAttributeBatchAsync( IReadOnlyList references, int maxConcurrency, From 4467124775d8d9d76f3db194f9fbfd97144767a8 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 17 Sep 2026 09:19:27 +0700 Subject: [PATCH 49/66] test(discovery): lock hierarchy GVA budget suppression --- .../Mms/MmsSmartDiscoveryPolicyTests.cs | 55 +++++++++++++++++++ 1 file changed, 55 insertions(+) diff --git a/tests/AR.Iec61850.Tests/Mms/MmsSmartDiscoveryPolicyTests.cs b/tests/AR.Iec61850.Tests/Mms/MmsSmartDiscoveryPolicyTests.cs index 438b50e9..f69ed5f8 100644 --- a/tests/AR.Iec61850.Tests/Mms/MmsSmartDiscoveryPolicyTests.cs +++ b/tests/AR.Iec61850.Tests/Mms/MmsSmartDiscoveryPolicyTests.cs @@ -66,6 +66,61 @@ public void LogicalNodePlanner_UsesOneCandidatePerLogicalNodeNotPerLeaf() Assert.Contains(roots, root => root.Domain == "LD0" && root.Item == "XCBR1"); } + [Fact] + public void TypeBudget_SelectsOnlyLogicalNodesProvenByLiveDirectory() + { + var directory = BuildDirectory(); + var selected = MmsSmartTypeProbePolicy.SelectLiveLogicalNodeRoots( + directory, + [ + new MmsObjectReference("ld0", "xcbr1", string.Empty), + new MmsObjectReference("LD0", "MISSING1", string.Empty), + new MmsObjectReference("LD0", "MMXU1$MX$PhV", "MX"), + new MmsObjectReference("LD0", "MMXU1", string.Empty), + new MmsObjectReference("LD0", "MMXU1", string.Empty) + ]); + + Assert.Equal(2, selected.Length); + Assert.Equal("MMXU1", selected[0].Item); + Assert.Equal("XCBR1", selected[1].Item); + Assert.All(selected, reference => Assert.Equal("LD0", reference.Domain)); + } + + [Fact] + public void TypeBudget_DoesNotRepeatExactDataObjectRootAsLeafFallback() + { + var dataObjectPoint = new MmsFcResolvedPoint + { + Domain = "LD0", + LogicalNode = "XCBR1", + FunctionalConstraint = "ST", + DataObjectPath = "Pos", + MmsItemName = "XCBR1$ST$Pos" + }; + var descendantPoint = new MmsFcResolvedPoint + { + Domain = "LD0", + LogicalNode = "MMXU1", + FunctionalConstraint = "MX", + DataObjectPath = "A.phsA.cVal.mag.f", + MmsItemName = "MMXU1$MX$A$phsA$cVal$mag$f" + }; + var alreadyProbed = new[] + { + MmsSmartTypeProbePolicy.BuildDataObjectRoot(dataObjectPoint), + MmsSmartTypeProbePolicy.BuildDataObjectRoot(descendantPoint) + }; + + var fallback = MmsSmartTypeProbePolicy.BuildUnprobedExactFallbacks( + [dataObjectPoint, descendantPoint, descendantPoint], + alreadyProbed); + + var exact = Assert.Single(fallback); + Assert.Equal("LD0", exact.Domain); + Assert.Equal("MMXU1$MX$A$phsA$cVal$mag$f", exact.Item); + Assert.DoesNotContain(fallback, reference => reference.Item == "XCBR1$ST$Pos"); + } + [Fact] public void TypeCoverage_UsesParentHierarchyAndRejectsUnknownBranch() { From 2de8f4c6b73af9d9893bc214538f327ae4b624ba Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 17 Sep 2026 11:33:33 +0700 Subject: [PATCH 50/66] fix(scl): reject unresolved RCB dataset graphs --- .../Export/AuthoritativeLiveIedSclExporter.cs | 97 +++++++++++++++++-- 1 file changed, 89 insertions(+), 8 deletions(-) diff --git a/src/AR.Iec61850/Scl/Export/AuthoritativeLiveIedSclExporter.cs b/src/AR.Iec61850/Scl/Export/AuthoritativeLiveIedSclExporter.cs index 93ad0705..3f5f729e 100644 --- a/src/AR.Iec61850/Scl/Export/AuthoritativeLiveIedSclExporter.cs +++ b/src/AR.Iec61850/Scl/Export/AuthoritativeLiveIedSclExporter.cs @@ -22,13 +22,28 @@ public static LiveIedSclExportResult WriteFiles( options ??= new LiveIedSclExportOptions(); var result = LiveIedSclExporter.WriteFiles(model, sclPath, options); - var document = XDocument.Load(result.SclPath, LoadOptions.PreserveWhitespace); - if (!string.IsNullOrWhiteSpace(options.IedNameOverride)) - document = ApplyIdentity(document, model, options.IedNameOverride); - - document = ApplyReportControlConfiguration(document, model, options.ResolvedSchemaProfile); - document.Save(result.SclPath); - return result; + try + { + var document = XDocument.Load(result.SclPath, LoadOptions.PreserveWhitespace); + if (!string.IsNullOrWhiteSpace(options.IedNameOverride)) + document = ApplyIdentity(document, model, options.IedNameOverride); + + document = ApplyReportControlConfiguration(document, model, options.ResolvedSchemaProfile); + ValidateExportGraph(document); + document.Save(result.SclPath); + return result; + } + catch + { + // Authoritative export is all-or-nothing. The generic exporter writes its + // artifacts first, so remove them if authoritative identity/RCB/DataSet/FCDA + // validation rejects the generated graph. Never leave a half-valid CID behind. + DeleteIfExists(result.SclPath); + DeleteIfExists(result.ReportPath); + DeleteIfExists(result.SummaryPath); + DeleteIfExists(result.ExcludedAttributesPath); + throw; + } } public static XDocument ApplyIdentity( @@ -193,6 +208,72 @@ private static void ValidateReportControlIdentity( } } + private static void ValidateExportGraph(XDocument document) + { + foreach (var reportControl in document.Descendants(Scl + "ReportControl")) + { + var dataSetName = ((string?)reportControl.Attribute("datSet") ?? string.Empty).Trim(); + if (string.IsNullOrWhiteSpace(dataSetName)) + continue; + + var logicalNode = reportControl.Ancestors() + .FirstOrDefault(element => element.Name == Scl + "LN0" || element.Name == Scl + "LN") + ?? throw new InvalidDataException( + $"ReportControl '{(string?)reportControl.Attribute("name")}' is not contained by an LN/LN0 element."); + + var dataSets = logicalNode.Elements(Scl + "DataSet") + .Where(element => string.Equals( + ((string?)element.Attribute("name") ?? string.Empty).Trim(), + dataSetName, + StringComparison.Ordinal)) + .ToArray(); + if (dataSets.Length != 1) + { + throw new InvalidDataException( + $"ReportControl '{(string?)reportControl.Attribute("name")}' references DataSet '{dataSetName}', but the generated SCL contains {dataSets.Length} matching DataSet element(s) in the same logical node."); + } + + var members = dataSets[0].Elements(Scl + "FCDA").ToArray(); + if (members.Length == 0) + { + throw new InvalidDataException( + $"ReportControl '{(string?)reportControl.Attribute("name")}' references DataSet '{dataSetName}', but that DataSet contains no valid FCDA members."); + } + + foreach (var fcda in members) + { + RequireFcdaAttribute(fcda, "ldInst", dataSetName); + RequireFcdaAttribute(fcda, "lnClass", dataSetName); + RequireFcdaAttribute(fcda, "doName", dataSetName); + RequireFcdaAttribute(fcda, "fc", dataSetName); + } + } + } + + private static void RequireFcdaAttribute(XElement fcda, string attributeName, string dataSetName) + { + if (!string.IsNullOrWhiteSpace(((string?)fcda.Attribute(attributeName) ?? string.Empty).Trim())) + return; + + throw new InvalidDataException( + $"DataSet '{dataSetName}' contains an FCDA without required '{attributeName}' identity."); + } + + private static void DeleteIfExists(string? path) + { + if (string.IsNullOrWhiteSpace(path) || !File.Exists(path)) + return; + + try + { + File.Delete(path); + } + catch + { + // Keep the original authoritative-validation exception as the primary failure. + } + } + private static string XmlBool(bool value) => value ? "true" : "false"; private static string MatchMmsDomain( @@ -267,4 +348,4 @@ private static string SafeXmlName(string value) return "LIVE_IED"; return char.IsLetter(result[0]) || result[0] == '_' ? result : $"_{result}"; } -} \ No newline at end of file +} From c5b05bcb8ce217bf97fa074442af288507c312fd Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 17 Sep 2026 11:37:47 +0700 Subject: [PATCH 51/66] test(scl): cover authoritative RCB dataset graph validation --- ...tativeLiveIedSclExporterValidationTests.cs | 122 ++++++++++++++++++ 1 file changed, 122 insertions(+) create mode 100644 tests/AR.Iec61850.Tests/Engineering/AuthoritativeLiveIedSclExporterValidationTests.cs diff --git a/tests/AR.Iec61850.Tests/Engineering/AuthoritativeLiveIedSclExporterValidationTests.cs b/tests/AR.Iec61850.Tests/Engineering/AuthoritativeLiveIedSclExporterValidationTests.cs new file mode 100644 index 00000000..e9c1c5ba --- /dev/null +++ b/tests/AR.Iec61850.Tests/Engineering/AuthoritativeLiveIedSclExporterValidationTests.cs @@ -0,0 +1,122 @@ +using System.Reflection; +using System.Runtime.ExceptionServices; +using System.Xml.Linq; +using AR.Iec61850.Scl.Export; + +namespace AR.Iec61850.Tests.Engineering; + +public sealed class AuthoritativeLiveIedSclExporterValidationTests +{ + private static readonly XNamespace Scl = "http://www.iec.ch/61850/2003/SCL"; + + [Fact] + public void ValidateExportGraph_AcceptsResolvedDataSetWithValidFcda() + { + var document = Document( + new XElement(Scl + "DataSet", + new XAttribute("name", "Analog"), + new XElement(Scl + "FCDA", + new XAttribute("ldInst", "LD0"), + new XAttribute("lnClass", "MMXU"), + new XAttribute("lnInst", "1"), + new XAttribute("doName", "A"), + new XAttribute("daName", "phsA.cVal.mag.f"), + new XAttribute("fc", "MX"))), + new XElement(Scl + "ReportControl", + new XAttribute("name", "BRCB01"), + new XAttribute("datSet", "Analog"))); + + Validate(document); + } + + [Fact] + public void ValidateExportGraph_RejectsUnresolvedReportControlDataSet() + { + var document = Document( + new XElement(Scl + "ReportControl", + new XAttribute("name", "BRCB01"), + new XAttribute("datSet", "Missing"))); + + var error = Assert.Throws(() => Validate(document)); + + Assert.Contains("references DataSet 'Missing'", error.Message, StringComparison.Ordinal); + } + + [Fact] + public void ValidateExportGraph_RejectsEmptyDataSetBoundToReportControl() + { + var document = Document( + new XElement(Scl + "DataSet", new XAttribute("name", "Digital")), + new XElement(Scl + "ReportControl", + new XAttribute("name", "URCB01"), + new XAttribute("datSet", "Digital"))); + + var error = Assert.Throws(() => Validate(document)); + + Assert.Contains("contains no valid FCDA members", error.Message, StringComparison.Ordinal); + } + + [Fact] + public void ValidateExportGraph_RejectsFcdaMissingRequiredIdentity() + { + var document = Document( + new XElement(Scl + "DataSet", + new XAttribute("name", "Digital"), + new XElement(Scl + "FCDA", + new XAttribute("ldInst", "LD0"), + new XAttribute("lnClass", "GGIO"), + new XAttribute("doName", "Ind1"))), + new XElement(Scl + "ReportControl", + new XAttribute("name", "URCB01"), + new XAttribute("datSet", "Digital"))); + + var error = Assert.Throws(() => Validate(document)); + + Assert.Contains("without required 'fc' identity", error.Message, StringComparison.Ordinal); + } + + [Fact] + public void ValidateExportGraph_AllowsReportControlWithoutDataSetBinding() + { + var document = Document( + new XElement(Scl + "ReportControl", + new XAttribute("name", "URCB01"))); + + Validate(document); + } + + private static XDocument Document(params XElement[] logicalNodeChildren) + => new( + new XElement(Scl + "SCL", + new XElement(Scl + "IED", + new XAttribute("name", "IED_A"), + new XElement(Scl + "AccessPoint", + new XAttribute("name", "AP1"), + new XElement(Scl + "Server", + new XElement(Scl + "LDevice", + new XAttribute("inst", "LD0"), + new XElement(Scl + "LN0", + new XAttribute("lnClass", "LLN0"), + new XAttribute("inst", string.Empty), + logicalNodeChildren)))))))); + + private static void Validate(XDocument document) + { + var method = typeof(AuthoritativeLiveIedSclExporter).GetMethod( + "ValidateExportGraph", + BindingFlags.NonPublic | BindingFlags.Static) + ?? throw new MissingMethodException( + typeof(AuthoritativeLiveIedSclExporter).FullName, + "ValidateExportGraph"); + + try + { + method.Invoke(null, [document]); + } + catch (TargetInvocationException ex) when (ex.InnerException is not null) + { + ExceptionDispatchInfo.Capture(ex.InnerException).Throw(); + throw; + } + } +} From e05fadc3f69d128e01bac0199910f790de6fc61a Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 17 Sep 2026 12:09:08 +0700 Subject: [PATCH 52/66] test(scl): fix exporter validation document syntax --- .../AuthoritativeLiveIedSclExporterValidationTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/AR.Iec61850.Tests/Engineering/AuthoritativeLiveIedSclExporterValidationTests.cs b/tests/AR.Iec61850.Tests/Engineering/AuthoritativeLiveIedSclExporterValidationTests.cs index e9c1c5ba..e02e44e1 100644 --- a/tests/AR.Iec61850.Tests/Engineering/AuthoritativeLiveIedSclExporterValidationTests.cs +++ b/tests/AR.Iec61850.Tests/Engineering/AuthoritativeLiveIedSclExporterValidationTests.cs @@ -98,7 +98,7 @@ private static XDocument Document(params XElement[] logicalNodeChildren) new XElement(Scl + "LN0", new XAttribute("lnClass", "LLN0"), new XAttribute("inst", string.Empty), - logicalNodeChildren)))))))); + logicalNodeChildren))))))); private static void Validate(XDocument document) { From a8002e7c8663b64daca862e6791bff2c7d3eb343 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 17 Sep 2026 18:38:54 +0700 Subject: [PATCH 53/66] fix(scl): project runtime RCB siblings to logical controls --- .../Export/LiveRcbLogicalGroupProjector.cs | 235 ++++++++++++++++++ 1 file changed, 235 insertions(+) create mode 100644 src/AR.Iec61850/Scl/Export/LiveRcbLogicalGroupProjector.cs diff --git a/src/AR.Iec61850/Scl/Export/LiveRcbLogicalGroupProjector.cs b/src/AR.Iec61850/Scl/Export/LiveRcbLogicalGroupProjector.cs new file mode 100644 index 00000000..73829ad8 --- /dev/null +++ b/src/AR.Iec61850/Scl/Export/LiveRcbLogicalGroupProjector.cs @@ -0,0 +1,235 @@ +using System.Globalization; +using AR.Iec61850.Discovery; + +namespace AR.Iec61850.Scl.Export; + +/// +/// Projects concrete runtime RCB instances discovered over MMS into logical SCL +/// ReportControl definitions without changing the authoritative live model itself. +/// A collapse is allowed only when numbered siblings are contiguous from 01 and their +/// static configuration is compatible. Otherwise every runtime instance remains a +/// separate non-indexed ReportControl. +/// +internal static class LiveRcbLogicalGroupProjector +{ + internal sealed class Projection + { + public required LiveIedReportControlModel Representative { get; init; } + public required string LogicalName { get; init; } + public required string ReportId { get; init; } + public required IReadOnlyList RuntimeInstances { get; init; } + public bool Indexed => RuntimeInstances.Count > 1; + public int MaxInstances => RuntimeInstances.Count; + } + + private sealed record Candidate( + LiveIedReportControlModel Control, + int OriginalIndex, + string BaseName, + int InstanceIndex); + + public static IReadOnlyList Project( + IReadOnlyList runtimeControls) + { + ArgumentNullException.ThrowIfNull(runtimeControls); + if (runtimeControls.Count == 0) + return Array.Empty(); + + var candidates = runtimeControls + .Select((control, index) => TryParseInstance(control, index)) + .Where(candidate => candidate is not null) + .Cast() + .GroupBy( + candidate => new GroupKey( + candidate.Control.Domain.Trim(), + candidate.Control.LogicalNode.Trim(), + candidate.Control.Buffered, + candidate.BaseName), + GroupKeyComparer.Instance) + .ToDictionary(group => group.Key, group => group.ToArray(), GroupKeyComparer.Instance); + + var consumed = new HashSet(ReferenceEqualityComparer.Instance); + var projections = new List<(int Order, Projection Projection)>(); + + foreach (var group in candidates.Values) + { + if (!TryBuildLogicalGroup(group, out var projection, out var firstIndex)) + continue; + + foreach (var runtime in projection.RuntimeInstances) + consumed.Add(runtime); + projections.Add((firstIndex, projection)); + } + + for (var index = 0; index < runtimeControls.Count; index++) + { + var control = runtimeControls[index]; + if (consumed.Contains(control)) + continue; + + projections.Add((index, new Projection + { + Representative = control, + LogicalName = control.Name.Trim(), + ReportId = control.ReportId.Trim(), + RuntimeInstances = new[] { control } + })); + } + + return projections + .OrderBy(item => item.Order) + .Select(item => item.Projection) + .ToArray(); + } + + private static bool TryBuildLogicalGroup( + IReadOnlyList source, + out Projection projection, + out int firstIndex) + { + projection = null!; + firstIndex = int.MaxValue; + if (source.Count < 2) + return false; + + var ordered = source + .OrderBy(candidate => candidate.InstanceIndex) + .ThenBy(candidate => candidate.OriginalIndex) + .ToArray(); + + if (ordered.Select(candidate => candidate.InstanceIndex).Distinct().Count() != ordered.Length) + return false; + + for (var index = 0; index < ordered.Length; index++) + { + if (ordered[index].InstanceIndex != index + 1) + return false; + } + + var representative = ordered[0].Control; + if (ordered.Skip(1).Any(candidate => !StaticConfigurationMatches(representative, candidate.Control))) + return false; + + if (!TryResolveLogicalReportId(ordered, out var logicalReportId)) + return false; + + firstIndex = ordered.Min(candidate => candidate.OriginalIndex); + projection = new Projection + { + Representative = representative, + LogicalName = ordered[0].BaseName, + ReportId = logicalReportId, + RuntimeInstances = ordered.Select(candidate => candidate.Control).ToArray() + }; + return true; + } + + private static bool StaticConfigurationMatches( + LiveIedReportControlModel left, + LiveIedReportControlModel right) + => left.Buffered == right.Buffered && + Same(left.Domain, right.Domain) && + Same(left.LogicalNode, right.LogicalNode) && + Same(left.DataSetReference, right.DataSetReference) && + SameNumericText(left.ConfRev, right.ConfRev) && + SameNumericText(left.BufferTimeMs, right.BufferTimeMs) && + SameNumericText(left.IntegrityPeriodMs, right.IntegrityPeriodMs) && + Same(left.TriggerOptions, right.TriggerOptions) && + Same(left.OptionalFields, right.OptionalFields); + + private static bool TryResolveLogicalReportId( + IReadOnlyList ordered, + out string reportId) + { + reportId = ordered[0].Control.ReportId.Trim(); + if (ordered.All(candidate => Same(candidate.Control.ReportId, reportId))) + return true; + + string? baseReportId = null; + foreach (var candidate in ordered) + { + var value = candidate.Control.ReportId.Trim(); + if (string.IsNullOrWhiteSpace(value) || !TrySplitTwoDigitSuffix(value, out var currentBase, out var instanceIndex)) + return false; + if (instanceIndex != candidate.InstanceIndex) + return false; + + baseReportId ??= currentBase; + if (!Same(baseReportId, currentBase)) + return false; + } + + reportId = baseReportId ?? string.Empty; + return true; + } + + private static Candidate? TryParseInstance(LiveIedReportControlModel control, int originalIndex) + { + if (control is null || !TrySplitTwoDigitSuffix(control.Name.Trim(), out var baseName, out var instanceIndex)) + return null; + if (instanceIndex <= 0 || string.IsNullOrWhiteSpace(baseName)) + return null; + return new Candidate(control, originalIndex, baseName, instanceIndex); + } + + private static bool TrySplitTwoDigitSuffix(string value, out string baseName, out int instanceIndex) + { + baseName = string.Empty; + instanceIndex = 0; + if (string.IsNullOrWhiteSpace(value) || value.Length < 3) + return false; + + var suffix = value.AsSpan(value.Length - 2, 2); + if (!char.IsDigit(suffix[0]) || !char.IsDigit(suffix[1]) || + !int.TryParse(suffix, NumberStyles.None, CultureInfo.InvariantCulture, out instanceIndex)) + { + return false; + } + + baseName = value[..^2]; + return !string.IsNullOrWhiteSpace(baseName); + } + + private static bool Same(string? left, string? right) + => string.Equals(left?.Trim(), right?.Trim(), StringComparison.OrdinalIgnoreCase); + + private static bool SameNumericText(string? left, string? right) + { + var leftText = left?.Trim() ?? string.Empty; + var rightText = right?.Trim() ?? string.Empty; + if (ulong.TryParse(leftText, NumberStyles.Integer, CultureInfo.InvariantCulture, out var leftNumber) && + ulong.TryParse(rightText, NumberStyles.Integer, CultureInfo.InvariantCulture, out var rightNumber)) + { + return leftNumber == rightNumber; + } + + return Same(leftText, rightText); + } + + private readonly record struct GroupKey( + string Domain, + string LogicalNode, + bool Buffered, + string BaseName); + + private sealed class GroupKeyComparer : IEqualityComparer + { + public static GroupKeyComparer Instance { get; } = new(); + + public bool Equals(GroupKey x, GroupKey y) + => x.Buffered == y.Buffered && + Same(x.Domain, y.Domain) && + Same(x.LogicalNode, y.LogicalNode) && + Same(x.BaseName, y.BaseName); + + public int GetHashCode(GroupKey obj) + { + var hash = new HashCode(); + hash.Add(obj.Buffered); + hash.Add(obj.Domain, StringComparer.OrdinalIgnoreCase); + hash.Add(obj.LogicalNode, StringComparer.OrdinalIgnoreCase); + hash.Add(obj.BaseName, StringComparer.OrdinalIgnoreCase); + return hash.ToHashCode(); + } + } +} From 5ffff3d42be15fc47d8a366f9d8de03209e242e6 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 17 Sep 2026 18:39:52 +0700 Subject: [PATCH 54/66] fix(scl): collapse proven indexed RCB instances --- .../Export/AuthoritativeLiveIedSclExporter.cs | 272 +++++++++++++----- 1 file changed, 203 insertions(+), 69 deletions(-) diff --git a/src/AR.Iec61850/Scl/Export/AuthoritativeLiveIedSclExporter.cs b/src/AR.Iec61850/Scl/Export/AuthoritativeLiveIedSclExporter.cs index 3f5f729e..ccd70779 100644 --- a/src/AR.Iec61850/Scl/Export/AuthoritativeLiveIedSclExporter.cs +++ b/src/AR.Iec61850/Scl/Export/AuthoritativeLiveIedSclExporter.cs @@ -1,3 +1,4 @@ +using System.Globalization; using System.Xml.Linq; using AR.Iec61850.Discovery; using AR.Iec61850.Mms; @@ -31,7 +32,7 @@ public static LiveIedSclExportResult WriteFiles( document = ApplyReportControlConfiguration(document, model, options.ResolvedSchemaProfile); ValidateExportGraph(document); document.Save(result.SclPath); - return result; + return WithReportControlCount(result, document.Descendants(Scl + "ReportControl").Count()); } catch { @@ -108,106 +109,207 @@ public static XDocument ApplyReportControlConfiguration( ArgumentNullException.ThrowIfNull(schema); var document = new XDocument(source); - var reportControls = model.ReportControls.ToArray(); - foreach (var element in document.Descendants(Scl + "ReportControl")) + var runtimeControls = model.ReportControls.ToArray(); + var projections = LiveRcbLogicalGroupProjector.Project(runtimeControls); + + foreach (var projection in projections) { - var name = ((string?)element.Attribute("name") ?? string.Empty).Trim(); - var buffered = bool.TryParse((string?)element.Attribute("buffered"), out var parsedBuffered) && parsedBuffered; - var matches = reportControls - .Where(control => - control.Name.Equals(name, StringComparison.OrdinalIgnoreCase) && - control.Buffered == buffered) - .ToArray(); - var modelControl = matches.Length == 1 - ? matches[0] - : reportControls.Length == 1 - ? reportControls[0] - : null; - if (modelControl is null) - continue; + var runtimeElements = new List(projection.RuntimeInstances.Count); + foreach (var runtimeControl in projection.RuntimeInstances) + { + var matches = document.Descendants(Scl + "ReportControl") + .Where(element => MatchesRuntimeControl(element, runtimeControl)) + .ToArray(); + if (matches.Length != 1) + { + throw new InvalidDataException( + $"Live RCB '{runtimeControl.Reference}' matched {matches.Length} generated ReportControl element(s); exactly one is required before logical projection."); + } + runtimeElements.Add(matches[0]); + } + + if (runtimeElements.Distinct(ReferenceEqualityComparer.Instance).Count() != runtimeElements.Count) + throw new InvalidDataException("Multiple live RCB instances resolved to the same generated ReportControl element."); + + if (projection.Indexed && runtimeElements.Select(element => element.Parent).Distinct(ReferenceEqualityComparer.Instance).Count() != 1) + { + throw new InvalidDataException( + $"Indexed RCB group '{projection.LogicalName}' spans multiple logical nodes in the generated SCL."); + } - // MMS discovery returns a concrete RCB object name. IEC 61850-6 defines - // ReportControl@indexed with a default value of true; if the attribute is - // omitted, an engineering tool appends another two-digit instance suffix. - // Therefore A_BRCB_1201 would become the invalid A_BRCB_120101. Preserve - // the proven live object exactly as one non-indexed instance. - element.SetAttributeValue("name", SafeXmlName(modelControl.Name)); - element.SetAttributeValue("indexed", "false"); - var rptEnabled = element.Element(Scl + "RptEnabled") ?? new XElement(Scl + "RptEnabled"); - rptEnabled.SetAttributeValue("max", "1"); - foreach (var clientLn in rptEnabled.Elements(Scl + "ClientLN").ToArray()) - clientLn.Remove(); - if (rptEnabled.Parent is null) - element.Add(rptEnabled); - - var trigger = MmsReportControlFieldCodec.DecodeTriggerOptions(modelControl.TriggerOptions); - var triggerElement = element.Element(Scl + "TrgOps") ?? new XElement(Scl + "TrgOps"); - triggerElement.SetAttributeValue("dchg", XmlBool(trigger.DataChange)); - triggerElement.SetAttributeValue("qchg", XmlBool(trigger.QualityChange)); - triggerElement.SetAttributeValue("dupd", XmlBool(trigger.DataUpdate)); - triggerElement.SetAttributeValue("period", XmlBool(trigger.Integrity)); - triggerElement.SetAttributeValue( - "gi", - schema.SupportsTriggerGi ? XmlBool(trigger.GeneralInterrogation) : null); - if (triggerElement.Parent is null) - element.Add(triggerElement); - - var optional = MmsReportControlFieldCodec.DecodeOptionalFields(modelControl.OptionalFields); - var optionalElement = element.Element(Scl + "OptFields") ?? new XElement(Scl + "OptFields"); - optionalElement.SetAttributeValue("seqNum", XmlBool(optional.SequenceNumber)); - optionalElement.SetAttributeValue("timeStamp", XmlBool(optional.ReportTimestamp)); - optionalElement.SetAttributeValue("reasonCode", XmlBool(optional.ReasonForInclusion)); - optionalElement.SetAttributeValue("dataSet", XmlBool(optional.DataSetName)); - optionalElement.SetAttributeValue("dataRef", XmlBool(optional.DataReference)); - optionalElement.SetAttributeValue("bufOvfl", XmlBool(optional.BufferOverflow)); - optionalElement.SetAttributeValue("entryID", XmlBool(optional.EntryId)); - optionalElement.SetAttributeValue("configRef", XmlBool(optional.ConfigurationRevision)); - optionalElement.SetAttributeValue( - "segmentation", - schema.IsEdition2 ? XmlBool(optional.Segmentation) : null); - if (optionalElement.Parent is null) - element.Add(optionalElement); + var element = runtimeElements[0]; + foreach (var redundant in runtimeElements.Skip(1)) + redundant.Remove(); + + ApplyProjectedReportControlConfiguration(element, projection, schema); } var confReportControl = document.Descendants(Scl + "ConfReportControl").SingleOrDefault(); if (confReportControl is not null) - confReportControl.SetAttributeValue("max", reportControls.Length.ToString(System.Globalization.CultureInfo.InvariantCulture)); + confReportControl.SetAttributeValue("max", projections.Count.ToString(CultureInfo.InvariantCulture)); - ValidateReportControlIdentity(document, reportControls); + ValidateReportControlIdentity(document, projections); return document; } + private static void ApplyProjectedReportControlConfiguration( + XElement element, + LiveRcbLogicalGroupProjector.Projection projection, + SclSchemaProfileDescriptor schema) + { + var modelControl = projection.Representative; + element.SetAttributeValue("name", SafeXmlName(projection.LogicalName)); + element.SetAttributeValue("indexed", projection.Indexed ? "true" : "false"); + element.SetAttributeValue("rptID", string.IsNullOrWhiteSpace(projection.ReportId) ? null : projection.ReportId); + + var rptEnabled = element.Element(Scl + "RptEnabled") ?? new XElement(Scl + "RptEnabled"); + rptEnabled.SetAttributeValue("max", projection.MaxInstances.ToString(CultureInfo.InvariantCulture)); + foreach (var clientLn in rptEnabled.Elements(Scl + "ClientLN").ToArray()) + clientLn.Remove(); + if (rptEnabled.Parent is null) + element.Add(rptEnabled); + + var trigger = MmsReportControlFieldCodec.DecodeTriggerOptions(modelControl.TriggerOptions); + var triggerElement = element.Element(Scl + "TrgOps") ?? new XElement(Scl + "TrgOps"); + triggerElement.SetAttributeValue("dchg", XmlBool(trigger.DataChange)); + triggerElement.SetAttributeValue("qchg", XmlBool(trigger.QualityChange)); + triggerElement.SetAttributeValue("dupd", XmlBool(trigger.DataUpdate)); + triggerElement.SetAttributeValue("period", XmlBool(trigger.Integrity)); + triggerElement.SetAttributeValue( + "gi", + schema.SupportsTriggerGi ? XmlBool(trigger.GeneralInterrogation) : null); + if (triggerElement.Parent is null) + element.Add(triggerElement); + + var optional = MmsReportControlFieldCodec.DecodeOptionalFields(modelControl.OptionalFields); + var optionalElement = element.Element(Scl + "OptFields") ?? new XElement(Scl + "OptFields"); + optionalElement.SetAttributeValue("seqNum", XmlBool(optional.SequenceNumber)); + optionalElement.SetAttributeValue("timeStamp", XmlBool(optional.ReportTimestamp)); + optionalElement.SetAttributeValue("reasonCode", XmlBool(optional.ReasonForInclusion)); + optionalElement.SetAttributeValue("dataSet", XmlBool(optional.DataSetName)); + optionalElement.SetAttributeValue("dataRef", XmlBool(optional.DataReference)); + optionalElement.SetAttributeValue("bufOvfl", XmlBool(optional.BufferOverflow)); + optionalElement.SetAttributeValue("entryID", XmlBool(optional.EntryId)); + optionalElement.SetAttributeValue("configRef", XmlBool(optional.ConfigurationRevision)); + optionalElement.SetAttributeValue( + "segmentation", + schema.IsEdition2 ? XmlBool(optional.Segmentation) : null); + if (optionalElement.Parent is null) + element.Add(optionalElement); + } + private static void ValidateReportControlIdentity( XDocument document, - IReadOnlyCollection reportControls) + IReadOnlyList projections) { var exported = document.Descendants(Scl + "ReportControl").ToArray(); - if (exported.Length != reportControls.Count) + if (exported.Length != projections.Count) { throw new InvalidDataException( - $"Generated SCL contains {exported.Length} ReportControl element(s), but live discovery contains {reportControls.Count}."); + $"Generated SCL contains {exported.Length} logical ReportControl element(s), but live evidence projects to {projections.Count}."); } - foreach (var modelControl in reportControls) + foreach (var projection in projections) { var matches = exported.Where(element => - string.Equals((string?)element.Attribute("name"), SafeXmlName(modelControl.Name), StringComparison.Ordinal) && - string.Equals((string?)element.Attribute("indexed"), "false", StringComparison.OrdinalIgnoreCase)).ToArray(); + MatchesProjectedControl(element, projection)) + .ToArray(); if (matches.Length != 1) { throw new InvalidDataException( - $"Live RCB '{modelControl.Name}' was not exported exactly once as indexed=false."); + $"Logical RCB '{projection.LogicalName}' was not exported exactly once in its authoritative LD/LN context."); + } + + var expectedIndexed = projection.Indexed ? "true" : "false"; + if (!string.Equals((string?)matches[0].Attribute("indexed"), expectedIndexed, StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidDataException( + $"Logical RCB '{projection.LogicalName}' must be exported with indexed={expectedIndexed}."); } var rptEnabled = matches[0].Element(Scl + "RptEnabled"); - if (rptEnabled is null || !string.Equals((string?)rptEnabled.Attribute("max"), "1", StringComparison.Ordinal)) + var expectedMax = projection.MaxInstances.ToString(CultureInfo.InvariantCulture); + if (rptEnabled is null || !string.Equals((string?)rptEnabled.Attribute("max"), expectedMax, StringComparison.Ordinal)) { throw new InvalidDataException( - $"Live RCB '{modelControl.Name}' must be exported with RptEnabled max=1."); + $"Logical RCB '{projection.LogicalName}' must be exported with RptEnabled max={expectedMax}."); + } + + if (projection.Indexed) + { + foreach (var runtime in projection.RuntimeInstances) + { + if (string.Equals(runtime.Name, projection.LogicalName, StringComparison.OrdinalIgnoreCase)) + continue; + if (exported.Any(element => MatchesRuntimeControl(element, runtime))) + { + throw new InvalidDataException( + $"Concrete runtime RCB '{runtime.Name}' remained in SCL after projection to indexed logical control '{projection.LogicalName}'."); + } + } } } } + private static bool MatchesRuntimeControl(XElement element, LiveIedReportControlModel control) + { + var name = ((string?)element.Attribute("name") ?? string.Empty).Trim(); + if (!name.Equals(SafeXmlName(control.Name), StringComparison.OrdinalIgnoreCase)) + return false; + + var buffered = bool.TryParse((string?)element.Attribute("buffered"), out var parsedBuffered) && parsedBuffered; + return buffered == control.Buffered && MatchesControlContext(element, control.Domain, control.LogicalNode); + } + + private static bool MatchesProjectedControl( + XElement element, + LiveRcbLogicalGroupProjector.Projection projection) + { + var name = ((string?)element.Attribute("name") ?? string.Empty).Trim(); + if (!name.Equals(SafeXmlName(projection.LogicalName), StringComparison.OrdinalIgnoreCase)) + return false; + + var buffered = bool.TryParse((string?)element.Attribute("buffered"), out var parsedBuffered) && parsedBuffered; + return buffered == projection.Representative.Buffered && + MatchesControlContext( + element, + projection.Representative.Domain, + projection.Representative.LogicalNode); + } + + private static bool MatchesControlContext(XElement element, string domain, string logicalNode) + { + var lDevice = element.Ancestors(Scl + "LDevice").FirstOrDefault(); + var ln = element.Ancestors().FirstOrDefault(candidate => candidate.Name == Scl + "LN0" || candidate.Name == Scl + "LN"); + if (lDevice is null || ln is null) + return false; + + if (!MatchesLogicalNode(ln, logicalNode)) + return false; + + var targetDomain = domain.Trim(); + var inst = ((string?)lDevice.Attribute("inst") ?? string.Empty).Trim(); + var explicitLdName = ((string?)lDevice.Attribute("ldName") ?? string.Empty).Trim(); + var iedName = ((string?)lDevice.Ancestors(Scl + "IED").FirstOrDefault()?.Attribute("name") ?? string.Empty).Trim(); + var implicitDomain = $"{iedName}{inst}"; + + return targetDomain.Equals(inst, StringComparison.OrdinalIgnoreCase) || + (!string.IsNullOrWhiteSpace(explicitLdName) && targetDomain.Equals(explicitLdName, StringComparison.OrdinalIgnoreCase)) || + (!string.IsNullOrWhiteSpace(iedName) && targetDomain.Equals(implicitDomain, StringComparison.OrdinalIgnoreCase)); + } + + private static bool MatchesLogicalNode(XElement element, string logicalNode) + { + var target = logicalNode.Trim(); + if (element.Name == Scl + "LN0") + return target.Equals("LLN0", StringComparison.OrdinalIgnoreCase); + + var prefix = ((string?)element.Attribute("prefix") ?? string.Empty).Trim(); + var lnClass = ((string?)element.Attribute("lnClass") ?? string.Empty).Trim(); + var inst = ((string?)element.Attribute("inst") ?? string.Empty).Trim(); + return target.Equals($"{prefix}{lnClass}{inst}", StringComparison.OrdinalIgnoreCase); + } + private static void ValidateExportGraph(XDocument document) { foreach (var reportControl in document.Descendants(Scl + "ReportControl")) @@ -259,6 +361,38 @@ private static void RequireFcdaAttribute(XElement fcda, string attributeName, st $"DataSet '{dataSetName}' contains an FCDA without required '{attributeName}' identity."); } + private static LiveIedSclExportResult WithReportControlCount( + LiveIedSclExportResult source, + int logicalReportControlCount) + => new() + { + SchemaVersion = source.SchemaVersion, + GeneratedAtUtc = source.GeneratedAtUtc, + Profile = source.Profile, + SclSchema = source.SclSchema, + SclPath = source.SclPath, + ReportPath = source.ReportPath, + SummaryPath = source.SummaryPath, + ExcludedAttributesPath = source.ExcludedAttributesPath, + LogicalDeviceCount = source.LogicalDeviceCount, + LogicalNodeCount = source.LogicalNodeCount, + DataSetCount = source.DataSetCount, + ReportControlCount = logicalReportControlCount, + GooseControlBlockCount = source.GooseControlBlockCount, + SampledValueControlBlockCount = source.SampledValueControlBlockCount, + SettingGroupControlCount = source.SettingGroupControlCount, + LogControlCount = source.LogControlCount, + LNodeTypeCount = source.LNodeTypeCount, + DoTypeCount = source.DoTypeCount, + DaTypeCount = source.DaTypeCount, + EnumTypeCount = source.EnumTypeCount, + Warnings = source.Warnings, + ExcludedAttributes = source.ExcludedAttributes, + DataSetMappings = source.DataSetMappings, + ReportMappings = source.ReportMappings, + ControlBlockMappings = source.ControlBlockMappings + }; + private static void DeleteIfExists(string? path) { if (string.IsNullOrWhiteSpace(path) || !File.Exists(path)) From 4d489125c4d1843d05ca36ae70b69a38d5c323e2 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 17 Sep 2026 18:40:48 +0700 Subject: [PATCH 55/66] test(scl): lock evidence-based logical RCB projection --- ...tativeLiveIedSclExporterValidationTests.cs | 157 ++++++++++++++++++ 1 file changed, 157 insertions(+) diff --git a/tests/AR.Iec61850.Tests/Engineering/AuthoritativeLiveIedSclExporterValidationTests.cs b/tests/AR.Iec61850.Tests/Engineering/AuthoritativeLiveIedSclExporterValidationTests.cs index e02e44e1..599752d4 100644 --- a/tests/AR.Iec61850.Tests/Engineering/AuthoritativeLiveIedSclExporterValidationTests.cs +++ b/tests/AR.Iec61850.Tests/Engineering/AuthoritativeLiveIedSclExporterValidationTests.cs @@ -1,6 +1,7 @@ using System.Reflection; using System.Runtime.ExceptionServices; using System.Xml.Linq; +using AR.Iec61850.Discovery; using AR.Iec61850.Scl.Export; namespace AR.Iec61850.Tests.Engineering; @@ -85,6 +86,162 @@ public void ValidateExportGraph_AllowsReportControlWithoutDataSetBinding() Validate(document); } + [Fact] + public void ApplyReportControlConfiguration_CollapsesCompatibleRuntimeSiblingsToIndexedLogicalControl() + { + var controls = new[] + { + RuntimeControl("Buffer01", buffered: true, reportId: "RID_Buffer01", dataSet: "IED_ALD0/LLN0$Analog"), + RuntimeControl("Buffer02", buffered: true, reportId: "RID_Buffer02", dataSet: "IED_ALD0/LLN0$Analog") + }; + + var result = ApplyReportProjection(controls); + var exported = result.Descendants(Scl + "ReportControl").Single(); + + Assert.Equal("Buffer", (string?)exported.Attribute("name")); + Assert.Equal("true", (string?)exported.Attribute("indexed")); + Assert.Equal("RID_Buffer", (string?)exported.Attribute("rptID")); + Assert.Equal("2", (string?)exported.Element(Scl + "RptEnabled")?.Attribute("max")); + Assert.DoesNotContain(result.Descendants(Scl + "ReportControl"), element => + string.Equals((string?)element.Attribute("name"), "Buffer01", StringComparison.OrdinalIgnoreCase) || + string.Equals((string?)element.Attribute("name"), "Buffer02", StringComparison.OrdinalIgnoreCase)); + Assert.Equal("1", (string?)result.Descendants(Scl + "ConfReportControl").Single().Attribute("max")); + } + + [Fact] + public void ApplyReportControlConfiguration_LeavesSingletonConcreteAndNonIndexed() + { + var result = ApplyReportProjection(new[] + { + RuntimeControl("Buffer01", buffered: true, reportId: "RID_Buffer01", dataSet: "IED_ALD0/LLN0$Analog") + }); + var exported = result.Descendants(Scl + "ReportControl").Single(); + + Assert.Equal("Buffer01", (string?)exported.Attribute("name")); + Assert.Equal("false", (string?)exported.Attribute("indexed")); + Assert.Equal("1", (string?)exported.Element(Scl + "RptEnabled")?.Attribute("max")); + } + + [Fact] + public void ApplyReportControlConfiguration_DoesNotCollapseNonContiguousInstances() + { + var result = ApplyReportProjection(new[] + { + RuntimeControl("Buffer01", buffered: true, reportId: "RID_Buffer01", dataSet: "IED_ALD0/LLN0$Analog"), + RuntimeControl("Buffer03", buffered: true, reportId: "RID_Buffer03", dataSet: "IED_ALD0/LLN0$Analog") + }); + var exported = result.Descendants(Scl + "ReportControl").ToArray(); + + Assert.Equal(2, exported.Length); + Assert.All(exported, element => Assert.Equal("false", (string?)element.Attribute("indexed"))); + Assert.All(exported, element => Assert.Equal("1", (string?)element.Element(Scl + "RptEnabled")?.Attribute("max"))); + } + + [Fact] + public void ApplyReportControlConfiguration_DoesNotCollapseStaticConfigurationConflict() + { + var result = ApplyReportProjection(new[] + { + RuntimeControl("Unbuffer01", buffered: false, reportId: "RID_Unbuffer01", dataSet: "IED_ALD0/LLN0$Analog"), + RuntimeControl("Unbuffer02", buffered: false, reportId: "RID_Unbuffer02", dataSet: "IED_ALD0/LLN0$Digital") + }); + var exported = result.Descendants(Scl + "ReportControl").ToArray(); + + Assert.Equal(2, exported.Length); + Assert.Contains(exported, element => string.Equals((string?)element.Attribute("name"), "Unbuffer01", StringComparison.Ordinal)); + Assert.Contains(exported, element => string.Equals((string?)element.Attribute("name"), "Unbuffer02", StringComparison.Ordinal)); + Assert.All(exported, element => Assert.Equal("false", (string?)element.Attribute("indexed"))); + } + + [Fact] + public void ApplyReportControlConfiguration_ProjectsThirtyFourRuntimeInstancesToThirtyTwoLogicalControls() + { + var controls = Enumerable.Range(1, 30) + .Select(index => RuntimeControl($"Standalone_{index}_X", buffered: false, reportId: $"RID_{index}_X", dataSet: string.Empty)) + .Concat(new[] + { + RuntimeControl("Buffer01", buffered: true, reportId: "RID_Buffer01", dataSet: "IED_ALD0/LLN0$Analog"), + RuntimeControl("Buffer02", buffered: true, reportId: "RID_Buffer02", dataSet: "IED_ALD0/LLN0$Analog"), + RuntimeControl("Unbuffer01", buffered: false, reportId: "RID_Unbuffer01", dataSet: "IED_ALD0/LLN0$Digital"), + RuntimeControl("Unbuffer02", buffered: false, reportId: "RID_Unbuffer02", dataSet: "IED_ALD0/LLN0$Digital") + }) + .ToArray(); + + var result = ApplyReportProjection(controls); + var exported = result.Descendants(Scl + "ReportControl").ToArray(); + + Assert.Equal(34, controls.Length); + Assert.Equal(32, exported.Length); + Assert.Equal("32", (string?)result.Descendants(Scl + "ConfReportControl").Single().Attribute("max")); + + var buffer = exported.Single(element => string.Equals((string?)element.Attribute("name"), "Buffer", StringComparison.Ordinal)); + Assert.Equal("true", (string?)buffer.Attribute("indexed")); + Assert.Equal("2", (string?)buffer.Element(Scl + "RptEnabled")?.Attribute("max")); + + var unbuffer = exported.Single(element => string.Equals((string?)element.Attribute("name"), "Unbuffer", StringComparison.Ordinal)); + Assert.Equal("true", (string?)unbuffer.Attribute("indexed")); + Assert.Equal("2", (string?)unbuffer.Element(Scl + "RptEnabled")?.Attribute("max")); + } + + private static XDocument ApplyReportProjection(IReadOnlyList controls) + { + var source = ReportDocument(controls); + var model = new LiveIedModelDiscoveryDocument + { + IedName = "IED_A", + ReportControls = controls + }; + return AuthoritativeLiveIedSclExporter.ApplyReportControlConfiguration( + source, + model, + SclSchemaProfiles.Get(SclSchemaProfile.Edition2V31)); + } + + private static LiveIedReportControlModel RuntimeControl( + string name, + bool buffered, + string reportId, + string dataSet) + => new() + { + Reference = $"IED_ALD0/LLN0${(buffered ? "BR" : "RP")}${name}", + Domain = "IED_ALD0", + LogicalNode = "LLN0", + Name = name, + Buffered = buffered, + DataSetReference = dataSet, + ReportId = reportId, + ConfRev = "1", + TriggerOptions = "dchg,qchg,gi", + OptionalFields = "seqnum,timestamp,dataset,dataref", + BufferTimeMs = buffered ? "10" : "0", + IntegrityPeriodMs = "1000" + }; + + private static XDocument ReportDocument(IReadOnlyList controls) + => new( + new XElement(Scl + "SCL", + new XElement(Scl + "IED", + new XAttribute("name", "IED_A"), + new XElement(Scl + "Services", + new XElement(Scl + "ConfReportControl", + new XAttribute("max", controls.Count))), + new XElement(Scl + "AccessPoint", + new XAttribute("name", "AP1"), + new XElement(Scl + "Server", + new XElement(Scl + "LDevice", + new XAttribute("inst", "LD0"), + new XElement(Scl + "LN0", + new XAttribute("lnClass", "LLN0"), + controls.Select(control => + new XElement(Scl + "ReportControl", + new XAttribute("name", control.Name), + new XAttribute("buffered", control.Buffered ? "true" : "false"), + new XAttribute("confRev", "1"), + new XElement(Scl + "TrgOps"), + new XElement(Scl + "OptFields"), + new XElement(Scl + "RptEnabled", new XAttribute("max", "1"))))))))))); + private static XDocument Document(params XElement[] logicalNodeChildren) => new( new XElement(Scl + "SCL", From 09a6ae264966970f93ac37ea5b5403683214fa06 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 17 Sep 2026 21:23:53 +0700 Subject: [PATCH 56/66] feat(scl): add canonical communication evidence --- .../Discovery/LiveIedCanonicalModel.cs | 72 +++++++++++++++++++ 1 file changed, 72 insertions(+) create mode 100644 src/AR.Iec61850/Discovery/LiveIedCanonicalModel.cs diff --git a/src/AR.Iec61850/Discovery/LiveIedCanonicalModel.cs b/src/AR.Iec61850/Discovery/LiveIedCanonicalModel.cs new file mode 100644 index 00000000..8df91aa6 --- /dev/null +++ b/src/AR.Iec61850/Discovery/LiveIedCanonicalModel.cs @@ -0,0 +1,72 @@ +using AR.Iec61850.Scl; + +namespace AR.Iec61850.Discovery; + +/// +/// Canonical live IED snapshot used for interoperable export. The discovery tree and +/// the communication/association evidence that reached the same accepted MMS session +/// are kept together so exporters do not have to invent connection parameters. +/// +public sealed class LiveIedCanonicalModel +{ + public string SchemaVersion { get; init; } = "live-ied-canonical-v2"; + public DateTimeOffset GeneratedAtUtc { get; init; } = DateTimeOffset.UtcNow; + public LiveIedModelDiscoveryDocument Discovery { get; init; } = new(); + public LiveIedCommunicationEvidence Communication { get; init; } = new(); + + public string IedName => Discovery.IedName; + public string AccessPointName => string.IsNullOrWhiteSpace(Communication.AccessPointName) + ? Discovery.AccessPointName + : Communication.AccessPointName; +} + +/// +/// Communication evidence bound to the exact accepted association used for discovery. +/// Empty fields mean unknown; they must not be replaced by device-specific guesses. +/// +public sealed class LiveIedCommunicationEvidence +{ + public string Source { get; init; } = string.Empty; + public string AssociationProfileName { get; init; } = string.Empty; + public string Host { get; init; } = string.Empty; + public int Port { get; init; } = 102; + public string AccessPointName { get; init; } = "AP1"; + public string SubNetworkName { get; init; } = "StationBus"; + public string IpSubnet { get; init; } = string.Empty; + public string IpGateway { get; init; } = string.Empty; + public SclIsoAssociationAddress Association { get; init; } = new(); + + public bool HasInteroperableSclAssociation => + !string.IsNullOrWhiteSpace(Host) && + !string.IsNullOrWhiteSpace(Association.ApTitle) && + Association.AeQualifier is >= 0 and <= 65535 && + !string.IsNullOrWhiteSpace(Association.PresentationSelector) && + !string.IsNullOrWhiteSpace(Association.SessionSelector) && + !string.IsNullOrWhiteSpace(Association.TransportSelector); +} + +public static class LiveIedCanonicalModelBuilder +{ + public static LiveIedCanonicalModel Build( + LiveIedModelDiscoveryDocument discovery, + LiveIedCommunicationEvidence communication) + { + ArgumentNullException.ThrowIfNull(discovery); + ArgumentNullException.ThrowIfNull(communication); + + if (!string.IsNullOrWhiteSpace(discovery.Host) && + !string.IsNullOrWhiteSpace(communication.Host) && + !string.Equals(discovery.Host.Trim(), communication.Host.Trim(), StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidDataException( + $"Discovery host '{discovery.Host}' does not match accepted-association host '{communication.Host}'."); + } + + return new LiveIedCanonicalModel + { + Discovery = discovery, + Communication = communication, + GeneratedAtUtc = discovery.GeneratedAtUtc + }; + } +} From b081efefc167d505c6b4599174f08f04f53b443c Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 17 Sep 2026 21:24:07 +0700 Subject: [PATCH 57/66] feat(scl): retain accepted association evidence --- .../MmsClientSession.AssociationEvidence.cs | 62 +++++++++++++++++++ 1 file changed, 62 insertions(+) create mode 100644 src/AR.Iec61850/Mms/MmsClientSession.AssociationEvidence.cs diff --git a/src/AR.Iec61850/Mms/MmsClientSession.AssociationEvidence.cs b/src/AR.Iec61850/Mms/MmsClientSession.AssociationEvidence.cs new file mode 100644 index 00000000..5dcaf9b8 --- /dev/null +++ b/src/AR.Iec61850/Mms/MmsClientSession.AssociationEvidence.cs @@ -0,0 +1,62 @@ +using AR.Iec61850.Discovery; +using AR.Iec61850.Scl; + +namespace AR.Iec61850.Mms; + +public sealed partial class MmsClientSession +{ + /// + /// Projects the exact accepted built-in association profile into canonical SCL + /// communication evidence. This is session evidence, not an exporter default. + /// Profiles that do not carry a qualified called AP-title remain explicitly + /// unresolved so a safe-connection export can fail closed. + /// + public LiveIedCommunicationEvidence GetAcceptedCommunicationEvidence(string accessPointName = "AP1") + { + var accepted = LastAssociationAttempts.LastOrDefault(attempt => attempt.IsAccepted); + var profileName = accepted?.ProfileName?.Trim() ?? string.Empty; + var association = ResolveAcceptedRemoteAssociation(profileName); + + return new LiveIedCommunicationEvidence + { + Source = string.IsNullOrWhiteSpace(profileName) + ? "AcceptedAssociationUnavailable" + : "AcceptedAssociationProfile", + AssociationProfileName = profileName, + Host = _lastHost?.Trim() ?? string.Empty, + Port = _lastPort <= 0 ? 102 : _lastPort, + AccessPointName = string.IsNullOrWhiteSpace(accessPointName) ? "AP1" : accessPointName.Trim(), + Association = association + }; + } + + private static SclIsoAssociationAddress ResolveAcceptedRemoteAssociation(string profileName) + { + // These values describe the called/remote side encoded by the shipped runtime + // association payloads. They are deliberately tied to the accepted profile name, + // not to any IED identity or vendor. If a future profile changes its wire identity, + // its evidence mapping must change with it and the round-trip regression will fail. + if (string.Equals(profileName, "BalancedApTitle", StringComparison.Ordinal)) + { + return new SclIsoAssociationAddress + { + ApTitle = "1,1,1,999,1", + AeQualifierText = "12", + AeQualifier = 12, + PresentationSelector = "00000001", + SessionSelector = "0001", + TransportSelector = "0001" + }; + } + + // LegacyMinimal intentionally does not provide a qualified called AP-title. + // Exporting a guessed AP-title would create an SCL that looks valid but is not + // evidence-backed, so leave it unresolved and let canonical export fail closed. + return new SclIsoAssociationAddress + { + PresentationSelector = "00000001", + SessionSelector = "0001", + TransportSelector = "0001" + }; + } +} From d3d8c900bba8b7f514df92db0cec3f8eea81fd12 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 17 Sep 2026 21:24:46 +0700 Subject: [PATCH 58/66] feat(scl): export canonical model with round-trip validation --- .../Scl/Export/CanonicalLiveIedSclExporter.cs | 183 ++++++++++++++++++ 1 file changed, 183 insertions(+) create mode 100644 src/AR.Iec61850/Scl/Export/CanonicalLiveIedSclExporter.cs diff --git a/src/AR.Iec61850/Scl/Export/CanonicalLiveIedSclExporter.cs b/src/AR.Iec61850/Scl/Export/CanonicalLiveIedSclExporter.cs new file mode 100644 index 00000000..5917da58 --- /dev/null +++ b/src/AR.Iec61850/Scl/Export/CanonicalLiveIedSclExporter.cs @@ -0,0 +1,183 @@ +using System.Globalization; +using System.Xml.Linq; +using AR.Iec61850.Discovery; + +namespace AR.Iec61850.Scl.Export; + +/// +/// Interoperable export boundary for a canonical live IED snapshot. All connection +/// addressing comes from the accepted-association evidence stored in the model. +/// No device-specific AP-title or selector default is invented here. +/// +public static class CanonicalLiveIedSclExporter +{ + private static readonly XNamespace Scl = "http://www.iec.ch/61850/2003/SCL"; + + public static LiveIedSclExportResult WriteFiles( + LiveIedCanonicalModel canonical, + string sclPath, + SclSchemaProfile schemaProfile = SclSchemaProfile.Edition2V31, + string profile = "safe-connection") + { + ArgumentNullException.ThrowIfNull(canonical); + ValidateCanonicalCommunication(canonical); + + var communication = canonical.Communication; + var association = communication.Association; + LiveIedSclExportResult? result = null; + + try + { + result = AuthoritativeLiveIedSclExporter.WriteFiles( + canonical.Discovery, + sclPath, + new LiveIedSclExportOptions + { + Profile = profile, + SchemaProfile = schemaProfile, + SubNetworkName = string.IsNullOrWhiteSpace(communication.SubNetworkName) + ? "StationBus" + : communication.SubNetworkName.Trim(), + IpAddress = communication.Host.Trim(), + IedNameOverride = canonical.IedName, + IpSubnet = communication.IpSubnet?.Trim() ?? string.Empty, + IpGateway = communication.IpGateway?.Trim() ?? string.Empty, + OsiApTitle = association.ApTitle.Trim(), + OsiAeQualifier = association.AeQualifier!.Value.ToString(CultureInfo.InvariantCulture), + OsiPsel = association.PresentationSelector.Trim(), + OsiSsel = association.SessionSelector.Trim(), + OsiTsel = association.TransportSelector.Trim(), + IncludeDefaultOsiParameters = true + }); + + var document = XDocument.Load(result.SclPath, LoadOptions.PreserveWhitespace); + ApplyCanonicalCommunication(document, canonical); + PreserveRuntimeServiceCapacity(document, canonical.Discovery); + ValidateRoundTripAssociation(document, canonical); + document.Save(result.SclPath); + return result; + } + catch + { + DeleteIfExists(sclPath); + if (result is not null) + { + DeleteIfExists(result.ReportPath); + DeleteIfExists(result.SummaryPath); + DeleteIfExists(result.ExcludedAttributesPath); + } + throw; + } + } + + public static void ValidateCanonicalCommunication(LiveIedCanonicalModel canonical) + { + var communication = canonical.Communication; + var association = communication.Association; + var errors = new List(); + + if (string.IsNullOrWhiteSpace(canonical.IedName)) + errors.Add("Canonical IED identity is empty."); + if (string.IsNullOrWhiteSpace(canonical.AccessPointName)) + errors.Add("Canonical access-point identity is empty."); + if (string.IsNullOrWhiteSpace(communication.Host)) + errors.Add("Canonical communication evidence has no IP/host endpoint."); + if (string.IsNullOrWhiteSpace(association.ApTitle)) + errors.Add("Canonical communication evidence has no accepted remote OSI-AP-Title."); + if (association.AeQualifier is not (>= 0 and <= 65535)) + errors.Add("Canonical communication evidence has no valid accepted remote OSI-AE-Qualifier."); + if (string.IsNullOrWhiteSpace(association.PresentationSelector)) + errors.Add("Canonical communication evidence has no accepted remote OSI-PSEL."); + if (string.IsNullOrWhiteSpace(association.SessionSelector)) + errors.Add("Canonical communication evidence has no accepted remote OSI-SSEL."); + if (string.IsNullOrWhiteSpace(association.TransportSelector)) + errors.Add("Canonical communication evidence has no accepted remote OSI-TSEL."); + + if (errors.Count > 0) + throw new InvalidDataException(string.Join(" | ", errors)); + } + + private static void ApplyCanonicalCommunication(XDocument document, LiveIedCanonicalModel canonical) + { + var connectedAp = document.Descendants(Scl + "ConnectedAP").SingleOrDefault() + ?? throw new InvalidDataException("Generated SCL must contain exactly one ConnectedAP."); + connectedAp.SetAttributeValue("iedName", canonical.IedName); + connectedAp.SetAttributeValue("apName", canonical.AccessPointName); + + var subNetwork = connectedAp.Parent; + if (subNetwork is not null && subNetwork.Name == Scl + "SubNetwork") + { + subNetwork.SetAttributeValue( + "name", + string.IsNullOrWhiteSpace(canonical.Communication.SubNetworkName) + ? "StationBus" + : canonical.Communication.SubNetworkName.Trim()); + subNetwork.SetAttributeValue("type", "8-MMS"); + } + + var address = connectedAp.Element(Scl + "Address"); + if (address is null) + { + address = new XElement(Scl + "Address"); + connectedAp.Add(address); + } + address.RemoveNodes(); + + var association = canonical.Communication.Association; + AddP(address, "OSI-AP-Title", association.ApTitle); + AddP(address, "OSI-AE-Qualifier", association.AeQualifier!.Value.ToString(CultureInfo.InvariantCulture)); + AddP(address, "OSI-PSEL", association.PresentationSelector); + AddP(address, "OSI-SSEL", association.SessionSelector); + AddP(address, "IP", canonical.Communication.Host); + AddP(address, "OSI-TSEL", association.TransportSelector); + AddP(address, "IP-SUBNET", canonical.Communication.IpSubnet); + AddP(address, "IP-GATEWAY", canonical.Communication.IpGateway); + } + + private static void PreserveRuntimeServiceCapacity( + XDocument document, + LiveIedModelDiscoveryDocument discovery) + { + // ReportControl elements may be projected from concrete runtime instances to + // indexed logical controls, but Services/ConfReportControl@max describes the + // observed runtime capacity and therefore stays bound to the physical inventory. + var confReportControl = document.Descendants(Scl + "ConfReportControl").SingleOrDefault(); + if (confReportControl is not null && discovery.ReportControls.Count > 0) + { + confReportControl.SetAttributeValue( + "max", + discovery.ReportControls.Count.ToString(CultureInfo.InvariantCulture)); + } + } + + private static void ValidateRoundTripAssociation(XDocument document, LiveIedCanonicalModel canonical) + { + var profiles = SclMmsAssociationProfileReader.Read(document); + var remote = profiles.Find(canonical.IedName, canonical.AccessPointName) + ?? throw new InvalidDataException( + $"Generated SCL cannot round-trip its own ConnectedAP '{canonical.IedName}/{canonical.AccessPointName}'."); + + var plan = SclAssistedMmsAssociationPlanBuilder.BuildExact( + remote, + MmsLocalAssociationProfile.SclInteroperabilityDefault); + if (!plan.IsSuccess) + { + throw new InvalidDataException( + "Generated SCL failed its own MMS association-plan validation: " + + string.Join(" | ", plan.Errors)); + } + } + + private static void AddP(XElement address, string type, string? value) + { + if (string.IsNullOrWhiteSpace(value)) + return; + address.Add(new XElement(Scl + "P", new XAttribute("type", type), value.Trim())); + } + + private static void DeleteIfExists(string path) + { + if (!string.IsNullOrWhiteSpace(path) && File.Exists(path)) + File.Delete(path); + } +} From c8bc4898607a32338e0bf8962c2883adf162c069 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 17 Sep 2026 21:25:32 +0700 Subject: [PATCH 59/66] test(scl): lock canonical association round trip --- .../CanonicalLiveIedSclExporterTests.cs | 142 ++++++++++++++++++ 1 file changed, 142 insertions(+) create mode 100644 tests/AR.Iec61850.Tests/Engineering/CanonicalLiveIedSclExporterTests.cs diff --git a/tests/AR.Iec61850.Tests/Engineering/CanonicalLiveIedSclExporterTests.cs b/tests/AR.Iec61850.Tests/Engineering/CanonicalLiveIedSclExporterTests.cs new file mode 100644 index 00000000..233fccf1 --- /dev/null +++ b/tests/AR.Iec61850.Tests/Engineering/CanonicalLiveIedSclExporterTests.cs @@ -0,0 +1,142 @@ +using System.Xml.Linq; +using AR.Iec61850.Discovery; +using AR.Iec61850.Scl; +using AR.Iec61850.Scl.Export; + +namespace AR.Iec61850.Tests.Engineering; + +public sealed class CanonicalLiveIedSclExporterTests +{ + private static readonly XNamespace Scl = "http://www.iec.ch/61850/2003/SCL"; + + [Theory] + [InlineData(SclSchemaProfile.Edition2V31)] + [InlineData(SclSchemaProfile.Edition1V16)] + public void WriteFiles_RoundTripsCanonicalAssociationWithoutExporterDefaults(SclSchemaProfile schema) + { + var root = Path.Combine(Path.GetTempPath(), "ariec-canonical-scl-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(root); + var path = Path.Combine(root, schema == SclSchemaProfile.Edition2V31 ? "ied.iid" : "ied.icd"); + try + { + var canonical = CreateCanonical(); + CanonicalLiveIedSclExporter.WriteFiles(canonical, path, schema); + + var document = XDocument.Load(path); + var address = document.Descendants(Scl + "ConnectedAP").Single().Element(Scl + "Address")!; + var values = address.Elements(Scl + "P") + .ToDictionary(e => (string)e.Attribute("type")!, e => e.Value, StringComparer.OrdinalIgnoreCase); + + Assert.Equal("10.20.30.40", values["IP"]); + Assert.Equal("1,1,1,999,1", values["OSI-AP-Title"]); + Assert.Equal("12", values["OSI-AE-Qualifier"]); + Assert.Equal("00000001", values["OSI-PSEL"]); + Assert.Equal("0001", values["OSI-SSEL"]); + Assert.Equal("0001", values["OSI-TSEL"]); + Assert.False(values.ContainsKey("IP-SUBNET")); + Assert.False(values.ContainsKey("IP-GATEWAY")); + + var profiles = SclMmsAssociationProfileReader.Read(document); + var remote = Assert.Single(profiles.AccessPoints); + var plan = SclAssistedMmsAssociationPlanBuilder.BuildExact( + remote, + MmsLocalAssociationProfile.SclInteroperabilityDefault); + Assert.True(plan.IsSuccess, string.Join(" | ", plan.Errors)); + } + finally + { + if (Directory.Exists(root)) + Directory.Delete(root, recursive: true); + } + } + + [Fact] + public void ValidateCanonicalCommunication_RejectsMissingRemoteApTitle() + { + var canonical = CreateCanonical(); + canonical = new LiveIedCanonicalModel + { + Discovery = canonical.Discovery, + Communication = new LiveIedCommunicationEvidence + { + Host = canonical.Communication.Host, + AccessPointName = canonical.Communication.AccessPointName, + Association = new SclIsoAssociationAddress + { + AeQualifier = 12, + AeQualifierText = "12", + PresentationSelector = "00000001", + SessionSelector = "0001", + TransportSelector = "0001" + } + } + }; + + var ex = Assert.Throws(() => + CanonicalLiveIedSclExporter.ValidateCanonicalCommunication(canonical)); + Assert.Contains("OSI-AP-Title", ex.Message, StringComparison.Ordinal); + } + + [Fact] + public void WriteFiles_PreservesPhysicalReportCapacityAfterLogicalProjection() + { + var root = Path.Combine(Path.GetTempPath(), "ariec-canonical-rcb-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(root); + var path = Path.Combine(root, "ied.iid"); + try + { + var canonical = CreateCanonical(reportControlCount: 3); + CanonicalLiveIedSclExporter.WriteFiles(canonical, path); + var document = XDocument.Load(path); + var conf = document.Descendants(Scl + "ConfReportControl").Single(); + Assert.Equal("3", (string?)conf.Attribute("max")); + } + finally + { + if (Directory.Exists(root)) + Directory.Delete(root, recursive: true); + } + } + + private static LiveIedCanonicalModel CreateCanonical(int reportControlCount = 0) + { + var reports = Enumerable.Range(1, reportControlCount) + .Select(i => new LiveIedReportControlModel + { + Name = $"R{i:00}", + Reference = $"IEDLD0/LLN0$RP$R{i:00}", + Domain = "IEDLD0", + LogicalNode = "LLN0", + Buffered = false + }) + .ToArray(); + + return new LiveIedCanonicalModel + { + Discovery = new LiveIedModelDiscoveryDocument + { + Host = "10.20.30.40", + IedName = "IED", + AccessPointName = "AP1", + ReportControls = reports + }, + Communication = new LiveIedCommunicationEvidence + { + Source = "AcceptedAssociationProfile", + AssociationProfileName = "BalancedApTitle", + Host = "10.20.30.40", + Port = 102, + AccessPointName = "AP1", + Association = new SclIsoAssociationAddress + { + ApTitle = "1,1,1,999,1", + AeQualifierText = "12", + AeQualifier = 12, + PresentationSelector = "00000001", + SessionSelector = "0001", + TransportSelector = "0001" + } + } + }; + } +} From 1fb8f4bd7e3eaa1c261e736032ef4a8f5003fe49 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 17 Sep 2026 21:26:19 +0700 Subject: [PATCH 60/66] fix(scl): expose runtime capacity projection --- src/AR.Iec61850/Scl/Export/CanonicalLiveIedSclExporter.cs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/AR.Iec61850/Scl/Export/CanonicalLiveIedSclExporter.cs b/src/AR.Iec61850/Scl/Export/CanonicalLiveIedSclExporter.cs index 5917da58..13f898c2 100644 --- a/src/AR.Iec61850/Scl/Export/CanonicalLiveIedSclExporter.cs +++ b/src/AR.Iec61850/Scl/Export/CanonicalLiveIedSclExporter.cs @@ -1,6 +1,7 @@ using System.Globalization; using System.Xml.Linq; using AR.Iec61850.Discovery; +using AR.Iec61850.Scl; namespace AR.Iec61850.Scl.Export; @@ -72,6 +73,7 @@ public static LiveIedSclExportResult WriteFiles( public static void ValidateCanonicalCommunication(LiveIedCanonicalModel canonical) { + ArgumentNullException.ThrowIfNull(canonical); var communication = canonical.Communication; var association = communication.Association; var errors = new List(); @@ -134,10 +136,12 @@ private static void ApplyCanonicalCommunication(XDocument document, LiveIedCanon AddP(address, "IP-GATEWAY", canonical.Communication.IpGateway); } - private static void PreserveRuntimeServiceCapacity( + public static void PreserveRuntimeServiceCapacity( XDocument document, LiveIedModelDiscoveryDocument discovery) { + ArgumentNullException.ThrowIfNull(document); + ArgumentNullException.ThrowIfNull(discovery); // ReportControl elements may be projected from concrete runtime instances to // indexed logical controls, but Services/ConfReportControl@max describes the // observed runtime capacity and therefore stays bound to the physical inventory. From 6d3e793e3f6f260fffd9383e4d6c669d5034dbfe Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 17 Sep 2026 21:26:45 +0700 Subject: [PATCH 61/66] test(scl): isolate runtime capacity invariant --- .../CanonicalLiveIedSclExporterTests.cs | 48 ++++++------------- 1 file changed, 15 insertions(+), 33 deletions(-) diff --git a/tests/AR.Iec61850.Tests/Engineering/CanonicalLiveIedSclExporterTests.cs b/tests/AR.Iec61850.Tests/Engineering/CanonicalLiveIedSclExporterTests.cs index 233fccf1..aa509fbe 100644 --- a/tests/AR.Iec61850.Tests/Engineering/CanonicalLiveIedSclExporterTests.cs +++ b/tests/AR.Iec61850.Tests/Engineering/CanonicalLiveIedSclExporterTests.cs @@ -78,47 +78,30 @@ public void ValidateCanonicalCommunication_RejectsMissingRemoteApTitle() } [Fact] - public void WriteFiles_PreservesPhysicalReportCapacityAfterLogicalProjection() + public void PreserveRuntimeServiceCapacity_KeepsPhysicalCountIndependentOfLogicalProjection() { - var root = Path.Combine(Path.GetTempPath(), "ariec-canonical-rcb-" + Guid.NewGuid().ToString("N")); - Directory.CreateDirectory(root); - var path = Path.Combine(root, "ied.iid"); - try + var document = XDocument.Parse( + ""); + var discovery = new LiveIedModelDiscoveryDocument { - var canonical = CreateCanonical(reportControlCount: 3); - CanonicalLiveIedSclExporter.WriteFiles(canonical, path); - var document = XDocument.Load(path); - var conf = document.Descendants(Scl + "ConfReportControl").Single(); - Assert.Equal("3", (string?)conf.Attribute("max")); - } - finally - { - if (Directory.Exists(root)) - Directory.Delete(root, recursive: true); - } - } + ReportControls = Enumerable.Range(1, 34) + .Select(i => new LiveIedReportControlModel { Name = $"R{i:00}" }) + .ToArray() + }; - private static LiveIedCanonicalModel CreateCanonical(int reportControlCount = 0) - { - var reports = Enumerable.Range(1, reportControlCount) - .Select(i => new LiveIedReportControlModel - { - Name = $"R{i:00}", - Reference = $"IEDLD0/LLN0$RP$R{i:00}", - Domain = "IEDLD0", - LogicalNode = "LLN0", - Buffered = false - }) - .ToArray(); + CanonicalLiveIedSclExporter.PreserveRuntimeServiceCapacity(document, discovery); - return new LiveIedCanonicalModel + Assert.Equal("34", (string?)document.Descendants(Scl + "ConfReportControl").Single().Attribute("max")); + } + + private static LiveIedCanonicalModel CreateCanonical() + => new() { Discovery = new LiveIedModelDiscoveryDocument { Host = "10.20.30.40", IedName = "IED", - AccessPointName = "AP1", - ReportControls = reports + AccessPointName = "AP1" }, Communication = new LiveIedCommunicationEvidence { @@ -138,5 +121,4 @@ private static LiveIedCanonicalModel CreateCanonical(int reportControlCount = 0) } } }; - } } From 30c8820cf922028d3d13b6f5a355df1ff60ea455 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 18 Sep 2026 07:34:16 +0700 Subject: [PATCH 62/66] fix(scl): avoid out parameter capture in RCB grouping --- src/AR.Iec61850/Scl/Export/LiveRcbLogicalGroupProjector.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/AR.Iec61850/Scl/Export/LiveRcbLogicalGroupProjector.cs b/src/AR.Iec61850/Scl/Export/LiveRcbLogicalGroupProjector.cs index 73829ad8..beae54bf 100644 --- a/src/AR.Iec61850/Scl/Export/LiveRcbLogicalGroupProjector.cs +++ b/src/AR.Iec61850/Scl/Export/LiveRcbLogicalGroupProjector.cs @@ -142,7 +142,8 @@ private static bool TryResolveLogicalReportId( out string reportId) { reportId = ordered[0].Control.ReportId.Trim(); - if (ordered.All(candidate => Same(candidate.Control.ReportId, reportId))) + var initialReportId = reportId; + if (ordered.All(candidate => Same(candidate.Control.ReportId, initialReportId))) return true; string? baseReportId = null; From 101f7304db46ce892c2b8671a19c394001047f08 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 18 Sep 2026 07:54:26 +0700 Subject: [PATCH 63/66] feat(scl): retain exact FC-root instance values --- .../Discovery/LiveIedCanonicalModel.cs | 135 +++++++++++++++++- 1 file changed, 130 insertions(+), 5 deletions(-) diff --git a/src/AR.Iec61850/Discovery/LiveIedCanonicalModel.cs b/src/AR.Iec61850/Discovery/LiveIedCanonicalModel.cs index 8df91aa6..f9cdd4c1 100644 --- a/src/AR.Iec61850/Discovery/LiveIedCanonicalModel.cs +++ b/src/AR.Iec61850/Discovery/LiveIedCanonicalModel.cs @@ -1,18 +1,21 @@ +using AR.Iec61850.Mms; using AR.Iec61850.Scl; namespace AR.Iec61850.Discovery; /// -/// Canonical live IED snapshot used for interoperable export. The discovery tree and -/// the communication/association evidence that reached the same accepted MMS session -/// are kept together so exporters do not have to invent connection parameters. +/// Canonical live IED snapshot used for interoperable export. The discovery tree, +/// accepted communication evidence, and exact initial instance-value evidence are kept +/// together so exporters do not have to invent connection or engineering values. /// public sealed class LiveIedCanonicalModel { - public string SchemaVersion { get; init; } = "live-ied-canonical-v2"; + public string SchemaVersion { get; init; } = "live-ied-canonical-v3"; public DateTimeOffset GeneratedAtUtc { get; init; } = DateTimeOffset.UtcNow; public LiveIedModelDiscoveryDocument Discovery { get; init; } = new(); public LiveIedCommunicationEvidence Communication { get; init; } = new(); + public IReadOnlyList InstanceValues { get; init; } = + Array.Empty(); public string IedName => Discovery.IedName; public string AccessPointName => string.IsNullOrWhiteSpace(Communication.AccessPointName) @@ -20,6 +23,27 @@ public sealed class LiveIedCanonicalModel : Communication.AccessPointName; } +/// +/// One scalar leaf observed through an exact FC-root projection. Domain/LN/DO/path are +/// explicit so SCL instance data can be materialized without reparsing display strings. +/// +public sealed class LiveIedInstanceValueEvidence +{ + public string Domain { get; init; } = string.Empty; + public string LogicalNode { get; init; } = string.Empty; + public string DataObject { get; init; } = string.Empty; + public string AttributePath { get; init; } = string.Empty; + public string FunctionalConstraint { get; init; } = string.Empty; + public string SclBType { get; init; } = string.Empty; + public MmsDataValue Value { get; init; } = null!; + public string Source { get; init; } = "InitialFcRootRead"; + + public string Reference => + string.IsNullOrWhiteSpace(Domain) || string.IsNullOrWhiteSpace(LogicalNode) + ? string.Empty + : $"{Domain}/{LogicalNode}.{DataObject}.{AttributePath}".TrimEnd('.'); +} + /// /// Communication evidence bound to the exact accepted association used for discovery. /// Empty fields mean unknown; they must not be replaced by device-specific guesses. @@ -49,7 +73,8 @@ public static class LiveIedCanonicalModelBuilder { public static LiveIedCanonicalModel Build( LiveIedModelDiscoveryDocument discovery, - LiveIedCommunicationEvidence communication) + LiveIedCommunicationEvidence communication, + InitialFcReadExecutionResult? initialRead = null) { ArgumentNullException.ThrowIfNull(discovery); ArgumentNullException.ThrowIfNull(communication); @@ -66,7 +91,107 @@ public static LiveIedCanonicalModel Build( { Discovery = discovery, Communication = communication, + InstanceValues = BuildInstanceValues(initialRead), GeneratedAtUtc = discovery.GeneratedAtUtc }; } + + private static IReadOnlyList BuildInstanceValues( + InitialFcReadExecutionResult? initialRead) + { + if (initialRead is null || initialRead.Batches.Count == 0) + return Array.Empty(); + + var values = new List(); + var seen = new HashSet(StringComparer.OrdinalIgnoreCase); + + foreach (var batch in initialRead.Batches.OrderBy(batch => batch.BatchIndex)) + { + foreach (var projection in batch.Projections) + { + var domain = projection.Target.Domain?.Trim() ?? string.Empty; + var logicalNode = projection.Target.LogicalNode?.Trim() ?? string.Empty; + if (string.IsNullOrWhiteSpace(domain) || string.IsNullOrWhiteSpace(logicalNode)) + continue; + + foreach (var leaf in projection.Leaves) + { + if (leaf.Value is null || + leaf.Value.Kind is MmsDataKind.Structure or MmsDataKind.Array or MmsDataKind.Unknown) + { + continue; + } + + if (!TryResolveDataObject( + domain, + logicalNode, + leaf.Reference, + out var dataObject)) + { + continue; + } + + var attributePath = leaf.AttributePath?.Trim().Trim('.') ?? string.Empty; + if (string.IsNullOrWhiteSpace(dataObject) || + string.IsNullOrWhiteSpace(attributePath)) + { + continue; + } + + var functionalConstraint = (leaf.FunctionalConstraint ?? string.Empty) + .Trim() + .ToUpperInvariant(); + var key = string.Concat( + domain, "", + logicalNode, "", + dataObject, "", + attributePath, "", + functionalConstraint); + if (!seen.Add(key)) + continue; + + values.Add(new LiveIedInstanceValueEvidence + { + Domain = domain, + LogicalNode = logicalNode, + DataObject = dataObject, + AttributePath = attributePath, + FunctionalConstraint = functionalConstraint, + SclBType = leaf.SclBType?.Trim() ?? string.Empty, + Value = leaf.Value, + Source = "InitialFcRootRead" + }); + } + } + } + + return values + .OrderBy(value => value.Domain, StringComparer.Ordinal) + .ThenBy(value => value.LogicalNode, StringComparer.Ordinal) + .ThenBy(value => value.DataObject, StringComparer.Ordinal) + .ThenBy(value => value.AttributePath, StringComparer.Ordinal) + .ThenBy(value => value.FunctionalConstraint, StringComparer.Ordinal) + .ToArray(); + } + + private static bool TryResolveDataObject( + string domain, + string logicalNode, + string? reference, + out string dataObject) + { + dataObject = string.Empty; + var text = (reference ?? string.Empty).Trim().Replace('$', '.'); + if (string.IsNullOrWhiteSpace(text)) + return false; + + var prefix = $"{domain}/{logicalNode}."; + if (!text.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) + return false; + + var remainder = text[prefix.Length..].Trim('.'); + var separator = remainder.IndexOf('.'); + dataObject = (separator < 0 ? remainder : remainder[..separator]).Trim(); + return !string.IsNullOrWhiteSpace(dataObject); + } } From c2ca990d07904c13ac4a6cff1ab54c0cb4dca07a Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 18 Sep 2026 07:55:21 +0700 Subject: [PATCH 64/66] feat(scl): materialize exact live instance values --- .../Scl/Export/CanonicalLiveIedSclExporter.cs | 293 ++++++++++++++++++ 1 file changed, 293 insertions(+) diff --git a/src/AR.Iec61850/Scl/Export/CanonicalLiveIedSclExporter.cs b/src/AR.Iec61850/Scl/Export/CanonicalLiveIedSclExporter.cs index 13f898c2..5e1d1a69 100644 --- a/src/AR.Iec61850/Scl/Export/CanonicalLiveIedSclExporter.cs +++ b/src/AR.Iec61850/Scl/Export/CanonicalLiveIedSclExporter.cs @@ -1,6 +1,7 @@ using System.Globalization; using System.Xml.Linq; using AR.Iec61850.Discovery; +using AR.Iec61850.Mms; using AR.Iec61850.Scl; namespace AR.Iec61850.Scl.Export; @@ -54,6 +55,7 @@ public static LiveIedSclExportResult WriteFiles( var document = XDocument.Load(result.SclPath, LoadOptions.PreserveWhitespace); ApplyCanonicalCommunication(document, canonical); PreserveRuntimeServiceCapacity(document, canonical.Discovery); + ApplyCanonicalInstanceValues(document, canonical); ValidateRoundTripAssociation(document, canonical); document.Save(result.SclPath); return result; @@ -154,6 +156,297 @@ public static void PreserveRuntimeServiceCapacity( } } + public static void ApplyCanonicalInstanceValues( + XDocument document, + LiveIedCanonicalModel canonical) + { + ArgumentNullException.ThrowIfNull(document); + ArgumentNullException.ThrowIfNull(canonical); + if (canonical.InstanceValues.Count == 0) + return; + + var grouped = canonical.InstanceValues + .Where(value => + !string.IsNullOrWhiteSpace(value.Domain) && + !string.IsNullOrWhiteSpace(value.LogicalNode) && + !string.IsNullOrWhiteSpace(value.DataObject) && + !string.IsNullOrWhiteSpace(value.AttributePath) && + value.Value is not null) + .GroupBy( + value => string.Concat( + value.Domain.Trim(), "\u001F", + value.LogicalNode.Trim(), "\u001F", + value.DataObject.Trim(), "\u001F", + value.AttributePath.Trim()), + StringComparer.OrdinalIgnoreCase) + .OrderBy(group => group.Key, StringComparer.Ordinal); + + foreach (var group in grouped) + { + var candidates = group.ToArray(); + var formatted = candidates + .Select(candidate => TryFormatScalarValue(candidate.Value, out var text) + ? text + : null) + .Where(text => text is not null) + .Distinct(StringComparer.Ordinal) + .ToArray(); + if (formatted.Length == 0) + continue; + if (formatted.Length != 1) + { + throw new InvalidDataException( + $"Conflicting live instance values were observed for '{candidates[0].Reference}'."); + } + + var evidence = candidates[0]; + var logicalNodeModel = FindLogicalNodeModel(canonical.Discovery, evidence); + if (logicalNodeModel is null) + continue; + + var lDevice = FindExportedLogicalDevice(document, canonical, evidence.Domain); + if (lDevice is null) + continue; + + var logicalNode = FindExportedLogicalNode(lDevice, logicalNodeModel); + if (logicalNode is null || + !IsExportedAttributePath(document, logicalNode, evidence.DataObject, evidence.AttributePath)) + { + continue; + } + + var doi = GetOrAddChild(logicalNode, "DOI", evidence.DataObject); + var segments = evidence.AttributePath + .Split('.', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + if (segments.Length == 0) + continue; + + XElement parent = doi; + for (var index = 0; index < segments.Length - 1; index++) + parent = GetOrAddChild(parent, "SDI", segments[index]); + + var dai = GetOrAddChild(parent, "DAI", segments[^1]); + var existingValues = dai.Elements(Scl + "Val").ToArray(); + if (existingValues.Length > 1) + { + throw new InvalidDataException( + $"Generated SCL contains multiple Val elements for '{evidence.Reference}'."); + } + + if (existingValues.Length == 1) + { + if (!string.Equals(existingValues[0].Value, formatted[0], StringComparison.Ordinal)) + { + throw new InvalidDataException( + $"Generated SCL already contains a different Val for '{evidence.Reference}'."); + } + continue; + } + + dai.Add(new XElement(Scl + "Val", formatted[0])); + } + } + + private static LiveIedLogicalNodeModel? FindLogicalNodeModel( + LiveIedModelDiscoveryDocument discovery, + LiveIedInstanceValueEvidence evidence) + => discovery.LogicalDevices + .Where(device => string.Equals( + device.MmsDomain?.Trim(), + evidence.Domain.Trim(), + StringComparison.OrdinalIgnoreCase)) + .SelectMany(device => device.LogicalNodes) + .FirstOrDefault(node => string.Equals( + node.Name?.Trim(), + evidence.LogicalNode.Trim(), + StringComparison.OrdinalIgnoreCase)); + + private static XElement? FindExportedLogicalDevice( + XDocument document, + LiveIedCanonicalModel canonical, + string domain) + { + var normalizedDomain = domain.Trim(); + var iedName = canonical.IedName.Trim(); + var stripped = normalizedDomain.StartsWith(iedName, StringComparison.OrdinalIgnoreCase) && + normalizedDomain.Length > iedName.Length + ? normalizedDomain[iedName.Length..] + : normalizedDomain; + + var candidates = document.Descendants(Scl + "LDevice") + .Where(element => + { + var inst = ((string?)element.Attribute("inst") ?? string.Empty).Trim(); + return string.Equals(inst, stripped, StringComparison.OrdinalIgnoreCase) || + string.Equals(inst, normalizedDomain, StringComparison.OrdinalIgnoreCase); + }) + .ToArray(); + return candidates.Length == 1 ? candidates[0] : null; + } + + private static XElement? FindExportedLogicalNode( + XElement lDevice, + LiveIedLogicalNodeModel model) + { + if (string.Equals(model.Name, "LLN0", StringComparison.OrdinalIgnoreCase)) + return lDevice.Elements(Scl + "LN0").SingleOrDefault(); + + return lDevice.Elements(Scl + "LN") + .SingleOrDefault(element => + string.Equals( + ((string?)element.Attribute("prefix") ?? string.Empty).Trim(), + model.Prefix?.Trim() ?? string.Empty, + StringComparison.OrdinalIgnoreCase) && + string.Equals( + ((string?)element.Attribute("lnClass") ?? string.Empty).Trim(), + model.LnClass?.Trim() ?? string.Empty, + StringComparison.OrdinalIgnoreCase) && + string.Equals( + ((string?)element.Attribute("inst") ?? string.Empty).Trim(), + model.LnInst?.Trim() ?? string.Empty, + StringComparison.OrdinalIgnoreCase)); + } + + private static bool IsExportedAttributePath( + XDocument document, + XElement logicalNode, + string dataObjectName, + string attributePath) + { + var lnTypeId = ((string?)logicalNode.Attribute("lnType") ?? string.Empty).Trim(); + if (string.IsNullOrWhiteSpace(lnTypeId)) + return false; + + var templates = document.Root?.Element(Scl + "DataTypeTemplates"); + if (templates is null) + return false; + + var lnType = templates.Elements(Scl + "LNodeType") + .SingleOrDefault(element => string.Equals( + ((string?)element.Attribute("id") ?? string.Empty).Trim(), + lnTypeId, + StringComparison.Ordinal)); + var dataObject = lnType?.Elements(Scl + "DO") + .SingleOrDefault(element => string.Equals( + ((string?)element.Attribute("name") ?? string.Empty).Trim(), + dataObjectName.Trim(), + StringComparison.OrdinalIgnoreCase)); + var typeId = ((string?)dataObject?.Attribute("type") ?? string.Empty).Trim(); + if (string.IsNullOrWhiteSpace(typeId)) + return false; + + XElement? currentType = FindTemplateById(templates, "DOType", typeId); + var segments = attributePath + .Split('.', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + for (var index = 0; index < segments.Length; index++) + { + if (currentType is null) + return false; + + var segment = segments[index]; + var definition = currentType.Elements() + .SingleOrDefault(element => + (element.Name == Scl + "DA" || + element.Name == Scl + "BDA" || + element.Name == Scl + "SDO") && + string.Equals( + ((string?)element.Attribute("name") ?? string.Empty).Trim(), + segment, + StringComparison.OrdinalIgnoreCase)); + if (definition is null) + return false; + + var isLast = index == segments.Length - 1; + if (isLast) + return definition.Name == Scl + "DA" || definition.Name == Scl + "BDA"; + + var nestedTypeId = ((string?)definition.Attribute("type") ?? string.Empty).Trim(); + if (string.IsNullOrWhiteSpace(nestedTypeId)) + return false; + + currentType = definition.Name == Scl + "SDO" + ? FindTemplateById(templates, "DOType", nestedTypeId) + : FindTemplateById(templates, "DAType", nestedTypeId); + } + + return false; + } + + private static XElement? FindTemplateById( + XElement templates, + string localName, + string id) + => templates.Elements(Scl + localName) + .SingleOrDefault(element => string.Equals( + ((string?)element.Attribute("id") ?? string.Empty).Trim(), + id, + StringComparison.Ordinal)); + + private static XElement GetOrAddChild( + XElement parent, + string localName, + string name) + { + var existing = parent.Elements(Scl + localName) + .SingleOrDefault(element => string.Equals( + ((string?)element.Attribute("name") ?? string.Empty).Trim(), + name.Trim(), + StringComparison.OrdinalIgnoreCase)); + if (existing is not null) + return existing; + + var created = new XElement( + Scl + localName, + new XAttribute("name", name.Trim())); + parent.Add(created); + return created; + } + + private static bool TryFormatScalarValue( + MmsDataValue value, + out string text) + { + text = string.Empty; + switch (value.Kind) + { + case MmsDataKind.Boolean: + text = Convert.ToBoolean(value.Value, CultureInfo.InvariantCulture) + ? "true" + : "false"; + return true; + case MmsDataKind.Integer: + case MmsDataKind.Unsigned: + text = Convert.ToString(value.Value, CultureInfo.InvariantCulture) ?? string.Empty; + return true; + case MmsDataKind.FloatingPoint: + text = value.Value switch + { + float single => single.ToString("R", CultureInfo.InvariantCulture), + double number => number.ToString("R", CultureInfo.InvariantCulture), + _ => string.Empty + }; + return text.Length > 0; + case MmsDataKind.VisibleString: + case MmsDataKind.MmsString: + text = Convert.ToString(value.Value, CultureInfo.InvariantCulture) ?? string.Empty; + return true; + case MmsDataKind.UtcTime: + if (value.Value is Iec61850UtcTime utc) + { + text = Iec61850UtcTimeFormatter.FormatFullPrecisionUtc(utc); + return true; + } + return false; + case MmsDataKind.OctetString: + text = Convert.ToHexString(value.RawValue.ToArray()); + return true; + default: + // BitString/Quality, BinaryTime, arrays, structures, and unknown tags + // are intentionally omitted until their SCL lexical form is proven. + return false; + } + } + private static void ValidateRoundTripAssociation(XDocument document, LiveIedCanonicalModel canonical) { var profiles = SclMmsAssociationProfileReader.Read(document); From 3ce8d4bce5bb9395b0300a83f289396ef1b95d10 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 18 Sep 2026 07:55:52 +0700 Subject: [PATCH 65/66] test(scl): prove canonical instance value export --- .../CanonicalLiveIedSclExporterTests.cs | 177 ++++++++++++++++++ 1 file changed, 177 insertions(+) diff --git a/tests/AR.Iec61850.Tests/Engineering/CanonicalLiveIedSclExporterTests.cs b/tests/AR.Iec61850.Tests/Engineering/CanonicalLiveIedSclExporterTests.cs index aa509fbe..377c8d90 100644 --- a/tests/AR.Iec61850.Tests/Engineering/CanonicalLiveIedSclExporterTests.cs +++ b/tests/AR.Iec61850.Tests/Engineering/CanonicalLiveIedSclExporterTests.cs @@ -1,5 +1,6 @@ using System.Xml.Linq; using AR.Iec61850.Discovery; +using AR.Iec61850.Mms; using AR.Iec61850.Scl; using AR.Iec61850.Scl.Export; @@ -94,6 +95,182 @@ public void PreserveRuntimeServiceCapacity_KeepsPhysicalCountIndependentOfLogica Assert.Equal("34", (string?)document.Descendants(Scl + "ConfReportControl").Single().Attribute("max")); } + [Fact] + public void CanonicalBuilder_RetainsExactInitialFcLeafEvidence() + { + var discovery = CreateInstanceDiscovery(); + var communication = CreateCanonical().Communication; + var target = new InitialFcReadTarget + { + Domain = "IEDLD0", + LogicalNode = "GGIO1", + FunctionalConstraint = "ST", + MmsItemName = "GGIO1$ST", + DataObjects = + [ + new InitialFcReadDataObjectBinding + { + Name = "Ind1", + Reference = "IEDLD0/GGIO1.Ind1", + Leaves = + [ + new InitialFcReadLeafBinding + { + Reference = "IEDLD0/GGIO1.Ind1.stVal", + AttributePath = "stVal", + FunctionalConstraint = "ST", + SclBType = "BOOLEAN" + } + ] + } + ] + }; + var projection = InitialFcValueProjector.Project( + target, + MmsDataValue.Structure( + [ + MmsDataValue.Structure([MmsDataValue.Boolean(true)]) + ])); + Assert.True(projection.IsExact, string.Join(" | ", projection.Errors)); + + var execution = new InitialFcReadExecutionResult + { + Status = InitialFcReadExecutionStatus.Completed, + Batches = + [ + new InitialFcReadBatchExecution + { + BatchIndex = 0, + Targets = [target], + Projections = [projection] + } + ] + }; + + var canonical = LiveIedCanonicalModelBuilder.Build( + discovery, + communication, + execution); + + var value = Assert.Single(canonical.InstanceValues); + Assert.Equal("IEDLD0", value.Domain); + Assert.Equal("GGIO1", value.LogicalNode); + Assert.Equal("Ind1", value.DataObject); + Assert.Equal("stVal", value.AttributePath); + Assert.Equal(MmsDataKind.Boolean, value.Value.Kind); + } + + [Theory] + [InlineData(SclSchemaProfile.Edition2V31)] + [InlineData(SclSchemaProfile.Edition1V16)] + public void WriteFiles_MaterializesExactInstanceValueAgainstExportedTypeTree( + SclSchemaProfile schema) + { + var root = Path.Combine(Path.GetTempPath(), "ariec-canonical-instance-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(root); + var path = Path.Combine(root, schema == SclSchemaProfile.Edition2V31 ? "ied.iid" : "ied.icd"); + try + { + var baseline = CreateCanonical(); + var canonical = new LiveIedCanonicalModel + { + Discovery = CreateInstanceDiscovery(), + Communication = baseline.Communication, + InstanceValues = + [ + new LiveIedInstanceValueEvidence + { + Domain = "IEDLD0", + LogicalNode = "GGIO1", + DataObject = "Ind1", + AttributePath = "stVal", + FunctionalConstraint = "ST", + SclBType = "BOOLEAN", + Value = MmsDataValue.Boolean(true) + } + ] + }; + + CanonicalLiveIedSclExporter.WriteFiles(canonical, path, schema); + + var document = XDocument.Load(path); + var lDevice = document.Descendants(Scl + "LDevice") + .Single(element => (string?)element.Attribute("inst") == "LD0"); + var logicalNode = lDevice.Elements(Scl + "LN") + .Single(element => + (string?)element.Attribute("lnClass") == "GGIO" && + (string?)element.Attribute("inst") == "1"); + var val = logicalNode.Elements(Scl + "DOI") + .Single(element => (string?)element.Attribute("name") == "Ind1") + .Elements(Scl + "DAI") + .Single(element => (string?)element.Attribute("name") == "stVal") + .Element(Scl + "Val"); + + Assert.NotNull(val); + Assert.Equal("true", val!.Value); + } + finally + { + if (Directory.Exists(root)) + Directory.Delete(root, recursive: true); + } + } + + private static LiveIedModelDiscoveryDocument CreateInstanceDiscovery() + => new() + { + Host = "10.20.30.40", + IedName = "IED", + AccessPointName = "AP1", + LogicalDevices = + [ + new LiveIedLogicalDeviceModel + { + MmsDomain = "IEDLD0", + Inst = "IEDLD0", + LogicalNodes = + [ + new LiveIedLogicalNodeModel + { + Name = "GGIO1", + LnClass = "GGIO", + LnInst = "1", + ProposedLnTypeId = "LN_GGIO_1", + DataObjects = + [ + new LiveIedDataObjectModel + { + Reference = "IEDLD0/GGIO1.Ind1", + Name = "Ind1", + ProposedDoTypeId = "DO_SPS_Ind1", + InferredCdc = "SPS", + CdcConfidence = 0.99, + ConfidenceLevel = LiveIedDiscoveryConfidenceLevel.High, + Attributes = + [ + new LiveIedDataAttributeModel + { + ObjectReference = "IEDLD0/GGIO1.Ind1.stVal", + AttributePath = "stVal", + FunctionalConstraint = "ST", + MmsReference = "IEDLD0/GGIO1$ST$Ind1$stVal", + MmsItemName = "GGIO1$ST$Ind1$stVal", + Source = "GetNameList", + SclBType = "BOOLEAN", + MmsType = "Boolean", + TypeDiscoveryStatus = "Exact", + TypeSource = "GetVariableAccessAttributes", + TypeConfidence = LiveIedDiscoveryConfidenceLevel.Exact + } + ] + } + ] + } + ] + } + ] + }; + private static LiveIedCanonicalModel CreateCanonical() => new() { From a31e396f0bbc9507215a8c9d125e910555ed6f0a Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 18 Sep 2026 07:57:50 +0700 Subject: [PATCH 66/66] perf(discovery): read RCB structure before attribute fallback --- .../Mms/MmsClientSession.SmartDiscovery.cs | 145 ++++++++++++++++-- 1 file changed, 136 insertions(+), 9 deletions(-) diff --git a/src/AR.Iec61850/Mms/MmsClientSession.SmartDiscovery.cs b/src/AR.Iec61850/Mms/MmsClientSession.SmartDiscovery.cs index 7e356ab1..c6fbfb14 100644 --- a/src/AR.Iec61850/Mms/MmsClientSession.SmartDiscovery.cs +++ b/src/AR.Iec61850/Mms/MmsClientSession.SmartDiscovery.cs @@ -144,7 +144,7 @@ public async Task DiscoverSmartAsync( { try { - await EnrichReportInventoryAsync( + await EnrichReportInventorySmartAsync( inventory, Math.Max(0, options.MaxReportAttributeProbes), cancellationToken) @@ -217,14 +217,6 @@ await EnrichReportInventoryAsync( ? "report enrichment=partial" : "report enrichment=completed"; - if (options.ProbeReportAttributes && - options.MaxReportAttributeProbes > 0 && - inventory.ReportControls.Count > 0) - { - MarkSmartDiscoveryKpiAccountingPartial( - "report-enrichment confirmed Reads are not yet individually observed by the smart KPI recorder"); - } - UpdateSmartDiscoveryCompleteness(snapshot, iedDirectory, inventory, dataSetDirectories); var kpi = LastSmartDiscoveryKpi; var kpiSummary = kpi is null @@ -249,6 +241,141 @@ await EnrichReportInventoryAsync( }; } + private async Task EnrichReportInventorySmartAsync( + MmsReportInventory inventory, + int maxReportAttributeProbes, + CancellationToken cancellationToken) + { + if (maxReportAttributeProbes <= 0 || + inventory.ReportControls.Count == 0 || + !IsMmsInitiated) + { + return; + } + + foreach (var reportControl in inventory.ReportControls + .OrderByDescending(control => control.Buffered) + .ThenByDescending(control => + control.LogicalNode.Equals("LLN0", StringComparison.OrdinalIgnoreCase)) + .Take(maxReportAttributeProbes)) + { + cancellationToken.ThrowIfCancellationRequested(); + + // A complete RCB value is normally one MMS structure. Reading that structure + // first avoids 8-9 individual confirmed Reads per runtime instance while + // preserving the same static configuration evidence. + await TryReadReportControlStructureSmartAsync( + reportControl, + cancellationToken) + .ConfigureAwait(false); + + // Fall back only for static fields that the structured value did not expose. + // Every fallback is individually observed by the smart KPI recorder. + if (string.IsNullOrWhiteSpace(reportControl.DataSetReference)) + await TryReadReportAttributeSmartAsync(reportControl, "DatSet", cancellationToken).ConfigureAwait(false); + if (string.IsNullOrWhiteSpace(reportControl.ReportId)) + await TryReadReportAttributeSmartAsync(reportControl, "RptID", cancellationToken).ConfigureAwait(false); + if (string.IsNullOrWhiteSpace(reportControl.ConfRev)) + await TryReadReportAttributeSmartAsync(reportControl, "ConfRev", cancellationToken).ConfigureAwait(false); + if (string.IsNullOrWhiteSpace(reportControl.IntegrityPeriodMs)) + await TryReadReportAttributeSmartAsync(reportControl, "IntgPd", cancellationToken).ConfigureAwait(false); + if (string.IsNullOrWhiteSpace(reportControl.BufferTimeMs)) + await TryReadReportAttributeSmartAsync(reportControl, "BufTm", cancellationToken).ConfigureAwait(false); + if (string.IsNullOrWhiteSpace(reportControl.TriggerOptions)) + await TryReadReportAttributeSmartAsync(reportControl, "TrgOps", cancellationToken).ConfigureAwait(false); + if (string.IsNullOrWhiteSpace(reportControl.OptionalFields)) + await TryReadReportAttributeSmartAsync(reportControl, "OptFlds", cancellationToken).ConfigureAwait(false); + + if (reportControl.Buffered) + { + if (string.IsNullOrWhiteSpace(reportControl.ReservationTimeSeconds)) + await TryReadReportAttributeSmartAsync(reportControl, "ResvTms", cancellationToken).ConfigureAwait(false); + } + else if (string.IsNullOrWhiteSpace(reportControl.ReservationState)) + { + await TryReadReportAttributeSmartAsync(reportControl, "Resv", cancellationToken).ConfigureAwait(false); + } + + reportControl.Status = HasUsefulReportProbeData(reportControl) + ? "Attribute-probed" + : reportControl.Status; + } + } + + private async Task TryReadReportControlStructureSmartAsync( + MmsReportControlCandidate reportControl, + CancellationToken cancellationToken) + { + try + { + var reference = MmsObjectReference.Parse( + reportControl.Reference, + reportControl.FunctionalConstraint); + using var observation = ObserveSmartDiscoveryRequest( + "report-enrichment", + "Read", + $"RCB:{reference.Domain}/{reference.Item}"); + var result = await ReadSingleVariableAsync(reference, cancellationToken) + .ConfigureAwait(false); + observation.Complete(result.IsSuccess); + + reportControl.ProbeDiagnostics.Add( + $"RCB base {reference.Item}: {(result.IsSuccess ? "OK" : result.Message)}"); + if (result.IsSuccess && result.Value is not null) + ApplyReportControlStructure(reportControl, result.Value); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + reportControl.ProbeDiagnostics.Add( + $"RCB base structure read failed: {ex.GetType().Name}: {ex.Message}"); + } + } + + private async Task TryReadReportAttributeSmartAsync( + MmsReportControlCandidate reportControl, + string attribute, + CancellationToken cancellationToken) + { + if (!string.Equals(attribute, "DatSet", StringComparison.OrdinalIgnoreCase) && + !reportControl.Attributes.Contains(attribute, StringComparer.OrdinalIgnoreCase)) + { + return; + } + + try + { + var reference = MmsObjectReference.Parse( + $"{reportControl.Reference}.{attribute}", + reportControl.FunctionalConstraint); + using var observation = ObserveSmartDiscoveryRequest( + "report-enrichment", + "Read", + $"RCB:{reference.Domain}/{reference.Item}"); + var result = await ReadSingleVariableAsync(reference, cancellationToken) + .ConfigureAwait(false); + observation.Complete(result.IsSuccess); + + if (result.IsSuccess && result.Value is not null) + { + var text = NormalizeReportAttributeText(result.Value); + if (!string.IsNullOrWhiteSpace(text)) + ApplyReportAttributeText(reportControl, attribute, text); + reportControl.ProbeDiagnostics.Add( + $"{attribute} item={reference.Item}: OK {MmsDataValueRenderer.ToCompactString(result.Value)}"); + } + else + { + reportControl.ProbeDiagnostics.Add( + $"{attribute} item={reference.Item}: {result.Message}"); + } + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + reportControl.ProbeDiagnostics.Add( + $"{attribute}: exception {ex.GetType().Name}: {ex.Message}"); + } + } + internal int ResolveSmartDiscoveryWindow(MmsSmartDiscoveryOptions options) { ArgumentNullException.ThrowIfNull(options);