diff --git a/src/AR.Iec61850/Engineering/Canonical/CanonicalLiveModelAdapter.cs b/src/AR.Iec61850/Engineering/Canonical/CanonicalLiveModelAdapter.cs
index 0d9cb2db..8038c3f1 100644
--- a/src/AR.Iec61850/Engineering/Canonical/CanonicalLiveModelAdapter.cs
+++ b/src/AR.Iec61850/Engineering/Canonical/CanonicalLiveModelAdapter.cs
@@ -1,4 +1,5 @@
using AR.Iec61850.Discovery;
+using AR.Iec61850.Scl.Workspace;
namespace AR.Iec61850.Engineering.Canonical;
@@ -34,6 +35,24 @@ public static CanonicalIedModel FromLiveDiscovery(LiveIedModelDiscoveryDocument
});
}
+ ///
+ /// Public source-correct canonical ingress for an already opened SCL IED workspace.
+ /// Applications should use this instead of re-labeling the workspace DesignModel as
+ /// live discovery merely to reuse canonical planning.
+ ///
+ public static CanonicalIedModel FromSclWorkspace(SclIedWorkspace workspace)
+ {
+ ArgumentNullException.ThrowIfNull(workspace);
+
+ return FromSclProjection(
+ workspace.DesignModel,
+ sourceName: "SCL workspace",
+ sourceEdition: string.Empty,
+ iedName: workspace.IedName,
+ accessPointName: workspace.AccessPointName,
+ domainAliases: EmptyAliases);
+ }
+
internal static CanonicalIedModel FromSclProjection(
LiveIedModelDiscoveryDocument projection,
string sourceName,
diff --git a/src/AR.Iec61850/Mms/MmsCanonicalStaticRcbTargetResolver.cs b/src/AR.Iec61850/Mms/MmsCanonicalStaticRcbTargetResolver.cs
new file mode 100644
index 00000000..da44d3b8
--- /dev/null
+++ b/src/AR.Iec61850/Mms/MmsCanonicalStaticRcbTargetResolver.cs
@@ -0,0 +1,129 @@
+using AR.Iec61850.Engineering.Canonical;
+
+namespace AR.Iec61850.Mms;
+
+public sealed class MmsCanonicalStaticRcbTargetSegment
+{
+ public string DataSetReference { get; init; } = string.Empty;
+ public IReadOnlyList ConfiguredReportControlReferences { get; init; } = Array.Empty();
+ public IReadOnlyList ExactLiveReportControlReferences { get; init; } = Array.Empty();
+ public string Resolution { get; init; } = string.Empty;
+}
+
+public sealed class MmsCanonicalStaticRcbTargetResolution
+{
+ public IReadOnlySet ExactLiveReportControlReferences { get; init; }
+ = new HashSet(StringComparer.OrdinalIgnoreCase);
+ public IReadOnlyList Segments { get; init; }
+ = Array.Empty();
+ public IReadOnlyList Warnings { get; init; } = Array.Empty();
+
+ public bool HasTargets => ExactLiveReportControlReferences.Count > 0;
+ public int CoveredDataSetCount => Segments.Count;
+}
+
+///
+/// Resolves canonical configured-static coverage to concrete live RCB references without
+/// inventing indexed names.
+///
+/// Exact live identity is accepted from either:
+/// 1) an exact configured RCB reference match, or
+/// 2) a live RCB whose live DatSet binding exactly matches the canonical covered DataSet
+/// and whose BRCB/URCB family is declared for that segment.
+///
+/// If neither evidence path is available the segment remains unresolved. The caller may
+/// explicitly choose a broader diagnostic probe, but this resolver never guesses a sibling
+/// RCB merely because its name looks similar.
+///
+public static class MmsCanonicalStaticRcbTargetResolver
+{
+ public static MmsCanonicalStaticRcbTargetResolution Resolve(
+ CanonicalStaticReportCoveragePlan coverage,
+ MmsReportInventory liveInventory)
+ {
+ ArgumentNullException.ThrowIfNull(coverage);
+ ArgumentNullException.ThrowIfNull(liveInventory);
+
+ var allTargets = new HashSet(StringComparer.OrdinalIgnoreCase);
+ var segments = new List();
+ var warnings = new List();
+
+ foreach (var segment in coverage.Segments)
+ {
+ if (segment.SelectedSignalReferences.Length == 0)
+ continue;
+
+ var canonicalDataSet = Normalize(segment.DataSetReference);
+ var configuredReferences = segment.ReportControls
+ .Select(report => Normalize(report.Reference))
+ .Where(reference => reference.Length > 0)
+ .Distinct(StringComparer.OrdinalIgnoreCase)
+ .ToHashSet(StringComparer.OrdinalIgnoreCase);
+ var configuredBufferedFamilies = segment.ReportControls
+ .Select(report => report.Buffered)
+ .Distinct()
+ .ToHashSet();
+
+ var exactReferenceMatches = liveInventory.ReportControls
+ .Where(candidate => configuredReferences.Contains(Normalize(candidate.Reference)))
+ .ToArray();
+
+ var exactDataSetMatches = canonicalDataSet.Length == 0
+ ? Array.Empty()
+ : liveInventory.ReportControls
+ .Where(candidate =>
+ string.Equals(
+ Normalize(candidate.DataSetReference),
+ canonicalDataSet,
+ StringComparison.OrdinalIgnoreCase))
+ .Where(candidate =>
+ configuredBufferedFamilies.Count == 0 ||
+ configuredBufferedFamilies.Contains(candidate.Buffered))
+ .ToArray();
+
+ var matched = exactReferenceMatches
+ .Concat(exactDataSetMatches)
+ .Where(candidate => !string.IsNullOrWhiteSpace(candidate.Reference))
+ .DistinctBy(candidate => Normalize(candidate.Reference), StringComparer.OrdinalIgnoreCase)
+ .OrderBy(candidate => candidate.Reference, StringComparer.OrdinalIgnoreCase)
+ .ToArray();
+
+ foreach (var candidate in matched)
+ allTargets.Add(candidate.Reference);
+
+ var resolution = matched.Length > 0
+ ? exactDataSetMatches.Length > 0
+ ? "exact-live-dataset-binding"
+ : "exact-configured-rcb-reference"
+ : "unresolved";
+
+ if (matched.Length == 0)
+ {
+ warnings.Add(
+ $"Canonical static DataSet {segment.DataSetReference} covers selected signal(s), but no concrete live RCB could be proven by exact reference or exact live DatSet binding. No sibling/name-family guess was made.");
+ }
+
+ segments.Add(new MmsCanonicalStaticRcbTargetSegment
+ {
+ DataSetReference = segment.DataSetReference,
+ ConfiguredReportControlReferences = configuredReferences
+ .OrderBy(reference => reference, StringComparer.OrdinalIgnoreCase)
+ .ToArray(),
+ ExactLiveReportControlReferences = matched
+ .Select(candidate => candidate.Reference)
+ .ToArray(),
+ Resolution = resolution
+ });
+ }
+
+ return new MmsCanonicalStaticRcbTargetResolution
+ {
+ ExactLiveReportControlReferences = allTargets,
+ Segments = segments,
+ Warnings = warnings
+ };
+ }
+
+ private static string Normalize(string? value)
+ => MmsRcbAvailabilityEvaluator.NormalizeReference(value);
+}
diff --git a/src/AR.Iec61850/Mms/MmsClientSession.ConfiguredStaticDataSetCache.cs b/src/AR.Iec61850/Mms/MmsClientSession.ConfiguredStaticDataSetCache.cs
new file mode 100644
index 00000000..76729fcc
--- /dev/null
+++ b/src/AR.Iec61850/Mms/MmsClientSession.ConfiguredStaticDataSetCache.cs
@@ -0,0 +1,46 @@
+using System.Collections.Concurrent;
+
+namespace AR.Iec61850.Mms;
+
+///
+/// Association-scoped cache for immutable configured DataSet directories.
+///
+/// Only successfully read non-deletable DataSets are cached. Dynamic/deletable or
+/// unknown-mutability DataSets are never cached because their membership can change.
+/// The cache is cleared on every successful MMS association.
+///
+public sealed partial class MmsClientSession
+{
+ private readonly ConcurrentDictionary _configuredStaticDataSetDirectoryEvidence =
+ new(StringComparer.OrdinalIgnoreCase);
+
+ private void ResetConfiguredStaticDataSetDirectoryEvidence()
+ => _configuredStaticDataSetDirectoryEvidence.Clear();
+
+ private async Task<(MmsDataSetDirectoryResult Result, bool CacheHit)> GetAvailabilityDataSetDirectoryAsync(
+ string dataSetReference,
+ MmsIedModelDirectory? directory,
+ CancellationToken cancellationToken)
+ {
+ var key = MmsRcbAvailabilityEvaluator.NormalizeReference(dataSetReference);
+ if (key.Length > 0 &&
+ _configuredStaticDataSetDirectoryEvidence.TryGetValue(key, out var cached))
+ {
+ return (cached, true);
+ }
+
+ var result = await GetDataSetDirectoryAsync(
+ dataSetReference,
+ directory,
+ cancellationToken).ConfigureAwait(false);
+
+ if (key.Length > 0 &&
+ result.IsSuccess &&
+ result.IsDeletable == false)
+ {
+ _configuredStaticDataSetDirectoryEvidence[key] = result;
+ }
+
+ return (result, false);
+ }
+}
diff --git a/src/AR.Iec61850/Mms/MmsClientSession.RcbAvailability.cs b/src/AR.Iec61850/Mms/MmsClientSession.RcbAvailability.cs
index 22c40a57..5a859e87 100644
--- a/src/AR.Iec61850/Mms/MmsClientSession.RcbAvailability.cs
+++ b/src/AR.Iec61850/Mms/MmsClientSession.RcbAvailability.cs
@@ -16,39 +16,49 @@ public async Task CheckReportControlAvailabilityAsync(
var warnings = new List();
var snapshots = new List();
var dataSetDirectories = new Dictionary(StringComparer.OrdinalIgnoreCase);
+ var rcbStateLogicalReads = 0;
+ var dataSetDirectoryNetworkReads = 0;
+ var dataSetDirectoryCacheHits = 0;
+
var callerOwned = options.CallerOwnedRcbReferences
.Select(MmsRcbAvailabilityEvaluator.NormalizeReference)
.Where(reference => reference.Length > 0)
.ToHashSet(StringComparer.OrdinalIgnoreCase);
- var max = Math.Clamp(options.MaxReportControls, 1, 4096);
- var candidates = inventory.ReportControls
- .OrderByDescending(candidate => !string.IsNullOrWhiteSpace(candidate.DataSetReference))
- .ThenByDescending(candidate => candidate.Buffered)
- .ThenBy(candidate => candidate.Domain, StringComparer.OrdinalIgnoreCase)
- .ThenBy(candidate => candidate.LogicalNode, StringComparer.OrdinalIgnoreCase)
- .ThenBy(candidate => candidate.Name, StringComparer.OrdinalIgnoreCase)
- .Take(max)
- .ToArray();
-
- if (inventory.ReportControls.Count > candidates.Length)
- warnings.Add($"Availability check was bounded to {candidates.Length} of {inventory.ReportControls.Count} discovered RCBs.");
-
- foreach (var source in candidates)
+
+ var selection = MmsRcbAvailabilityTargetSelector.Select(inventory, options);
+ warnings.AddRange(selection.Warnings);
+
+ foreach (var source in selection.Candidates)
{
cancellationToken.ThrowIfCancellationRequested();
var candidate = CloneReportControl(source);
- // Preserve discovery/SCL-derived binding only as fallback evidence. Clear the
- // candidate before the forced live probe so a successful empty DatSet read can
- // be distinguished from a failed read that merely left an old value in memory.
+ // Preserve discovery/SCL-derived binding only as fallback evidence. Volatile
+ // runtime state is cleared before every live check so stale discovery values
+ // can never masquerade as just-in-time availability evidence.
var previouslyKnownDataSetReference = candidate.DataSetReference;
candidate.DataSetReference = string.Empty;
candidate.DataSetProbeState = MmsRcbDataSetProbeState.NotAttempted;
candidate.DataSetProbeMessage = string.Empty;
+ candidate.EnabledState = string.Empty;
+ candidate.ReservationState = string.Empty;
+ candidate.ReservationTimeSeconds = string.Empty;
+ candidate.Owner = string.Empty;
+
+ if (selection.TargetFilterApplied)
+ {
+ rcbStateLogicalReads += await ProbeTargetedReportControlAvailabilityAsync(
+ candidate,
+ cancellationToken).ConfigureAwait(false);
+ }
+ else
+ {
+ await ProbeReportControlAttributesAsync(candidate, cancellationToken).ConfigureAwait(false);
+ // Broad diagnostic mode intentionally retains full attribute/Owner evidence.
+ await ProbeOwnerReadOnlyAsync(candidate, cancellationToken).ConfigureAwait(false);
+ }
- await ProbeReportControlAttributesAsync(candidate, cancellationToken).ConfigureAwait(false);
CaptureDataSetProbeEvidence(candidate, previouslyKnownDataSetReference);
- await ProbeOwnerReadOnlyAsync(candidate, cancellationToken).ConfigureAwait(false);
MmsDataSetDirectoryResult? dataSetDirectory = null;
var dataSetReference = MmsRcbAvailabilityEvaluator.NormalizeReference(candidate.DataSetReference);
@@ -56,7 +66,15 @@ public async Task CheckReportControlAvailabilityAsync(
{
if (!dataSetDirectories.TryGetValue(dataSetReference, out dataSetDirectory))
{
- dataSetDirectory = await GetDataSetDirectoryAsync(candidate.DataSetReference, directory, cancellationToken).ConfigureAwait(false);
+ var directoryEvidence = await GetAvailabilityDataSetDirectoryAsync(
+ candidate.DataSetReference,
+ directory,
+ cancellationToken).ConfigureAwait(false);
+ dataSetDirectory = directoryEvidence.Result;
+ if (directoryEvidence.CacheHit)
+ dataSetDirectoryCacheHits++;
+ else
+ dataSetDirectoryNetworkReads++;
dataSetDirectories[dataSetReference] = dataSetDirectory;
}
}
@@ -71,6 +89,13 @@ public async Task CheckReportControlAvailabilityAsync(
return new MmsRcbAvailabilityResult
{
CheckedAtUtc = checkedAt,
+ InventoryReportControlCount = inventory.ReportControls.Count,
+ TargetFilterApplied = selection.TargetFilterApplied,
+ RequestedTargetReportControlCount = selection.RequestedTargetCount,
+ MatchedTargetReportControlCount = selection.MatchedTargetCount,
+ TargetedRcbStateLogicalReadCount = rcbStateLogicalReads,
+ DataSetDirectoryNetworkReadCount = dataSetDirectoryNetworkReads,
+ DataSetDirectoryCacheHitCount = dataSetDirectoryCacheHits,
ReportControls = snapshots
.OrderByDescending(item => item.DataSetMemberCount > 0)
.ThenBy(item => AvailabilityRank(item.Availability))
diff --git a/src/AR.Iec61850/Mms/MmsClientSession.TargetedRcbAvailability.cs b/src/AR.Iec61850/Mms/MmsClientSession.TargetedRcbAvailability.cs
new file mode 100644
index 00000000..940a75bc
--- /dev/null
+++ b/src/AR.Iec61850/Mms/MmsClientSession.TargetedRcbAvailability.cs
@@ -0,0 +1,86 @@
+namespace AR.Iec61850.Mms;
+
+///
+/// Minimal live-state probe used only after an exact RCB target has already been chosen.
+///
+/// The broad diagnostic probe reads many presentation/configuration attributes. The runtime
+/// hot path instead tries one complete RCB structure read, then fills only missing DatSet,
+/// RptEna, reservation and Owner evidence required for safe availability decisions.
+///
+public sealed partial class MmsClientSession
+{
+ private async Task ProbeTargetedReportControlAvailabilityAsync(
+ MmsReportControlCandidate reportControl,
+ CancellationToken cancellationToken)
+ {
+ ArgumentNullException.ThrowIfNull(reportControl);
+
+ var logicalReads = 0;
+
+ // Prefer one complete RCB structure read. On interoperable peers this provides
+ // DatSet + RptEna + reservation state in one confirmed service round trip.
+ await TryReadReportControlStructureAsync(reportControl, cancellationToken).ConfigureAwait(false);
+ logicalReads++;
+
+ if (MmsRcbAvailabilityEvaluator.ParseBool(reportControl.EnabledState) is null)
+ {
+ await ProbeReportControlAttributeAsync(
+ reportControl,
+ "RptEna",
+ value => reportControl.EnabledState = NormalizeReportAttributeText(value),
+ cancellationToken).ConfigureAwait(false);
+ logicalReads++;
+ }
+
+ if (string.IsNullOrWhiteSpace(reportControl.DataSetReference))
+ {
+ await ProbeReportControlAttributeAsync(
+ reportControl,
+ "DatSet",
+ value =>
+ {
+ var text = NormalizeReportAttributeText(value);
+ reportControl.DataSetReference = string.IsNullOrWhiteSpace(text)
+ ? string.Empty
+ : NormalizeReportedDataSetReference(reportControl.Domain, text);
+ },
+ cancellationToken).ConfigureAwait(false);
+ logicalReads++;
+ }
+
+ if (reportControl.Buffered)
+ {
+ if (MmsRcbAvailabilityEvaluator.ParseUnsigned(reportControl.ReservationTimeSeconds) is null &&
+ reportControl.Attributes.Contains("ResvTms", StringComparer.OrdinalIgnoreCase))
+ {
+ await ProbeReportControlAttributeAsync(
+ reportControl,
+ "ResvTms",
+ value => reportControl.ReservationTimeSeconds = NormalizeReportAttributeText(value),
+ cancellationToken).ConfigureAwait(false);
+ logicalReads++;
+ }
+ }
+ else if (MmsRcbAvailabilityEvaluator.ParseBool(reportControl.ReservationState) is null &&
+ reportControl.Attributes.Contains("Resv", StringComparer.OrdinalIgnoreCase))
+ {
+ await ProbeReportControlAttributeAsync(
+ reportControl,
+ "Resv",
+ value => reportControl.ReservationState = NormalizeReportAttributeText(value),
+ cancellationToken).ConfigureAwait(false);
+ logicalReads++;
+ }
+
+ // Owner is outside the base structure on many devices. If exposed, one exact
+ // read is cheaper and safer than inferring ownership from missing metadata.
+ if (reportControl.Attributes.Contains("Owner", StringComparer.OrdinalIgnoreCase))
+ {
+ await ProbeOwnerReadOnlyAsync(reportControl, cancellationToken).ConfigureAwait(false);
+ logicalReads++;
+ }
+
+ reportControl.Status = "Targeted-availability-probed";
+ return logicalReads;
+ }
+}
diff --git a/src/AR.Iec61850/Mms/MmsClientSession.cs b/src/AR.Iec61850/Mms/MmsClientSession.cs
index d84e22c5..636182e7 100644
--- a/src/AR.Iec61850/Mms/MmsClientSession.cs
+++ b/src/AR.Iec61850/Mms/MmsClientSession.cs
@@ -766,6 +766,7 @@ private async Task AssociateAsync(bool resetAssociationDiagnostics, Cancellation
State = MmsAssociationState.MmsInitiated;
LastHandshakeMessage = result.Message;
ResetReportSemanticTypeEvidence();
+ ResetConfiguredStaticDataSetDirectoryEvidence();
_receivePump.Start(cancellationToken);
return;
}
diff --git a/src/AR.Iec61850/Mms/MmsRcbAvailability.cs b/src/AR.Iec61850/Mms/MmsRcbAvailability.cs
index d916a64f..aed0a3a3 100644
--- a/src/AR.Iec61850/Mms/MmsRcbAvailability.cs
+++ b/src/AR.Iec61850/Mms/MmsRcbAvailability.cs
@@ -22,6 +22,15 @@ public sealed class MmsRcbAvailabilityOptions
{
public int MaxReportControls { get; init; } = 512;
public bool ReadDataSetDirectories { get; init; } = true;
+
+ ///
+ /// Optional exact live RCB references to probe. Empty means broad diagnostic mode.
+ /// Runtime acquisition hot paths should supply only the concrete RCB instances that
+ /// canonical coverage/family resolution has already selected.
+ ///
+ public IReadOnlySet TargetReportControlReferences { get; init; }
+ = new HashSet(StringComparer.OrdinalIgnoreCase);
+
public IReadOnlySet CallerOwnedRcbReferences { get; init; }
= new HashSet(StringComparer.OrdinalIgnoreCase);
}
@@ -70,10 +79,22 @@ public sealed class MmsRcbAvailabilityResult
public IReadOnlyList ReportControls { get; init; } = Array.Empty();
public IReadOnlyList Warnings { get; init; } = Array.Empty();
+ public int InventoryReportControlCount { get; init; }
+ public bool TargetFilterApplied { get; init; }
+ public int RequestedTargetReportControlCount { get; init; }
+ public int MatchedTargetReportControlCount { get; init; }
+ public int TargetedRcbStateLogicalReadCount { get; init; }
+ public int DataSetDirectoryNetworkReadCount { get; init; }
+ public int DataSetDirectoryCacheHitCount { get; init; }
+
public int AvailableCount => ReportControls.Count(item => item.Availability == MmsRcbOperationalAvailability.Available);
public int InUseCount => ReportControls.Count(item => item.Availability == MmsRcbOperationalAvailability.InUse);
public int UnknownCount => ReportControls.Count(item => item.Availability == MmsRcbOperationalAvailability.Unknown);
- public string Summary => $"RCB availability checked: total={ReportControls.Count}, available={AvailableCount}, in-use={InUseCount}, unknown={UnknownCount}.";
+ public string Summary =>
+ $"RCB availability checked: inventory={InventoryReportControlCount}, probed={ReportControls.Count}, " +
+ $"targeted={TargetFilterApplied.ToString().ToLowerInvariant()}, available={AvailableCount}, in-use={InUseCount}, unknown={UnknownCount}, " +
+ $"targetedRcbStateReads={(TargetFilterApplied ? TargetedRcbStateLogicalReadCount.ToString() : "n/a")}, " +
+ $"datasetReads={DataSetDirectoryNetworkReadCount}, datasetCacheHits={DataSetDirectoryCacheHitCount}.";
}
public static class MmsRcbAvailabilityEvaluator
diff --git a/src/AR.Iec61850/Mms/MmsRcbAvailabilityTargetSelector.cs b/src/AR.Iec61850/Mms/MmsRcbAvailabilityTargetSelector.cs
new file mode 100644
index 00000000..4963505c
--- /dev/null
+++ b/src/AR.Iec61850/Mms/MmsRcbAvailabilityTargetSelector.cs
@@ -0,0 +1,75 @@
+namespace AR.Iec61850.Mms;
+
+public sealed class MmsRcbAvailabilitySelection
+{
+ public IReadOnlyList Candidates { get; init; } = Array.Empty();
+ public bool TargetFilterApplied { get; init; }
+ public int RequestedTargetCount { get; init; }
+ public int MatchedTargetCount { get; init; }
+ public int EligibleCountBeforeLimit { get; init; }
+ public IReadOnlyList Warnings { get; init; } = Array.Empty();
+}
+
+///
+/// Pure candidate selector for availability probing.
+///
+/// Exact target references are never broadened to family/neighbor RCBs. Family resolution
+/// belongs to the canonical/report-family planner before this stage. This keeps the network
+/// hot path proportional to the RCBs that can actually serve the selected signals.
+///
+public static class MmsRcbAvailabilityTargetSelector
+{
+ public static MmsRcbAvailabilitySelection Select(
+ MmsReportInventory inventory,
+ MmsRcbAvailabilityOptions options)
+ {
+ ArgumentNullException.ThrowIfNull(inventory);
+ ArgumentNullException.ThrowIfNull(options);
+
+ var warnings = new List();
+ var targets = options.TargetReportControlReferences
+ .Select(MmsRcbAvailabilityEvaluator.NormalizeReference)
+ .Where(reference => reference.Length > 0)
+ .ToHashSet(StringComparer.OrdinalIgnoreCase);
+ var targeted = targets.Count > 0;
+
+ IEnumerable eligible = inventory.ReportControls;
+ if (targeted)
+ {
+ eligible = eligible.Where(candidate =>
+ targets.Contains(MmsRcbAvailabilityEvaluator.NormalizeReference(candidate.Reference)));
+ }
+
+ var eligibleArray = eligible.ToArray();
+ var matched = targeted ? eligibleArray.Length : 0;
+ if (targeted && matched < targets.Count)
+ {
+ warnings.Add(
+ $"Targeted RCB availability matched {matched} of {targets.Count} requested exact live reference(s); " +
+ "unmatched targets were not broadened to unrelated RCBs.");
+ }
+
+ var max = Math.Clamp(options.MaxReportControls, 1, 4096);
+ var candidates = eligibleArray
+ .OrderByDescending(candidate => !string.IsNullOrWhiteSpace(candidate.DataSetReference))
+ .ThenByDescending(candidate => candidate.Buffered)
+ .ThenBy(candidate => candidate.Domain, StringComparer.OrdinalIgnoreCase)
+ .ThenBy(candidate => candidate.LogicalNode, StringComparer.OrdinalIgnoreCase)
+ .ThenBy(candidate => candidate.Name, StringComparer.OrdinalIgnoreCase)
+ .Take(max)
+ .ToArray();
+
+ if (eligibleArray.Length > candidates.Length)
+ warnings.Add($"Availability check was bounded to {candidates.Length} of {eligibleArray.Length} eligible RCBs.");
+
+ return new MmsRcbAvailabilitySelection
+ {
+ Candidates = candidates,
+ TargetFilterApplied = targeted,
+ RequestedTargetCount = targets.Count,
+ MatchedTargetCount = matched,
+ EligibleCountBeforeLimit = eligibleArray.Length,
+ Warnings = warnings
+ };
+ }
+}
diff --git a/tests/AR.Iec61850.Tests/Engineering/CanonicalLiveModelAdapterTests.cs b/tests/AR.Iec61850.Tests/Engineering/CanonicalLiveModelAdapterTests.cs
index 34364f1e..15d3b73d 100644
--- a/tests/AR.Iec61850.Tests/Engineering/CanonicalLiveModelAdapterTests.cs
+++ b/tests/AR.Iec61850.Tests/Engineering/CanonicalLiveModelAdapterTests.cs
@@ -1,5 +1,6 @@
using AR.Iec61850.Discovery;
using AR.Iec61850.Engineering.Canonical;
+using AR.Iec61850.Scl.Workspace;
namespace AR.Iec61850.Tests.Engineering;
@@ -71,6 +72,39 @@ public void FromLiveDiscovery_Preserves_Identity_Provenance_And_Compacts_Repeate
Assert.Equal("192.0.2.10", model.Communication.Host.Value);
}
+ [Fact]
+ public void FromSclWorkspace_Preserves_Scl_Ingress_Provenance()
+ {
+ var workspace = new SclIedWorkspace
+ {
+ IedName = "IED_SCL",
+ AccessPointName = "P1",
+ DesignModel = new LiveIedModelDiscoveryDocument
+ {
+ Source = "SclDesign",
+ IedName = "IED_SCL",
+ AccessPointName = "P1",
+ DataSets =
+ [
+ new LiveIedDataSetModel
+ {
+ Reference = "IED_SCLLD0/LLN0$Events",
+ Domain = "IED_SCLLD0",
+ LogicalNode = "LLN0",
+ Name = "Events"
+ }
+ ]
+ }
+ };
+
+ var model = CanonicalLiveModelAdapter.FromSclWorkspace(workspace);
+
+ Assert.Equal(CanonicalIngressKind.SclFile, model.Source.Ingress);
+ Assert.Equal(CanonicalEvidenceSource.SclDeclared, model.Identity.Provenance.Source);
+ Assert.Equal("IED_SCL", model.Identity.Name);
+ Assert.Equal("IED_SCLLD0/LLN0.Events", model.DataSets.Single().Reference);
+ }
+
private static LiveIedDataAttributeModel Attribute(string reference, string path)
=> new()
{
diff --git a/tests/AR.Iec61850.Tests/Mms/MmsCanonicalStaticRcbTargetResolverTests.cs b/tests/AR.Iec61850.Tests/Mms/MmsCanonicalStaticRcbTargetResolverTests.cs
new file mode 100644
index 00000000..12bcbef0
--- /dev/null
+++ b/tests/AR.Iec61850.Tests/Mms/MmsCanonicalStaticRcbTargetResolverTests.cs
@@ -0,0 +1,125 @@
+using AR.Iec61850.Engineering.Canonical;
+using AR.Iec61850.Mms;
+
+namespace AR.Iec61850.Tests.Mms;
+
+public sealed class MmsCanonicalStaticRcbTargetResolverTests
+{
+ [Fact]
+ public void Exact_Live_Dataset_Binding_Targets_Only_Covered_Rcb_Family()
+ {
+ var coverage = Coverage(
+ dataSet: "LD0/LLN0.Events",
+ configured: new CanonicalReportControl
+ {
+ Reference = "LD0/LLN0.BR.Rpt",
+ DataSetReference = "LD0/LLN0.Events",
+ Buffered = true
+ });
+
+ var inventory = Inventory(
+ Candidate("LD0/LLN0.BR.Rpt01", "LD0/LLN0.Events", buffered: true),
+ Candidate("LD0/LLN0.BR.Rpt02", "LD0/LLN0.Events", buffered: true),
+ Candidate("LD0/LLN0.RP.Rpt01", "LD0/LLN0.Events", buffered: false),
+ Candidate("LD0/LLN0.BR.Other01", "LD0/LLN0.Other", buffered: true));
+
+ var resolution = MmsCanonicalStaticRcbTargetResolver.Resolve(coverage, inventory);
+
+ Assert.True(resolution.HasTargets);
+ Assert.Equal(
+ new[] { "LD0/LLN0.BR.Rpt01", "LD0/LLN0.BR.Rpt02" },
+ resolution.ExactLiveReportControlReferences.OrderBy(x => x, StringComparer.OrdinalIgnoreCase).ToArray());
+ Assert.Equal("exact-live-dataset-binding", resolution.Segments.Single().Resolution);
+ }
+
+ [Fact]
+ public void Exact_Configured_Reference_Can_Target_When_Live_Dataset_Binding_Is_Not_Yet_Populated()
+ {
+ var coverage = Coverage(
+ dataSet: "LD0/LLN0.Events",
+ configured: new CanonicalReportControl
+ {
+ Reference = "LD0/LLN0.BR.Rpt01",
+ DataSetReference = "LD0/LLN0.Events",
+ Buffered = true
+ });
+
+ var inventory = Inventory(
+ Candidate("LD0/LLN0$BR$Rpt01", string.Empty, buffered: true),
+ Candidate("LD0/LLN0.BR.Rpt02", string.Empty, buffered: true));
+
+ var resolution = MmsCanonicalStaticRcbTargetResolver.Resolve(coverage, inventory);
+
+ Assert.Equal(new[] { "LD0/LLN0$BR$Rpt01" }, resolution.ExactLiveReportControlReferences);
+ Assert.Equal("exact-configured-rcb-reference", resolution.Segments.Single().Resolution);
+ }
+
+ [Fact]
+ public void Unresolved_Segment_Does_Not_Guess_Name_Family()
+ {
+ var coverage = Coverage(
+ dataSet: "LD0/LLN0.Events",
+ configured: new CanonicalReportControl
+ {
+ Reference = "LD0/LLN0.BR.Rpt",
+ DataSetReference = "LD0/LLN0.Events",
+ Buffered = true
+ });
+
+ var inventory = Inventory(
+ Candidate("LD0/LLN0.BR.Rpt01", string.Empty, buffered: true),
+ Candidate("LD0/LLN0.BR.Rpt02", string.Empty, buffered: true));
+
+ var resolution = MmsCanonicalStaticRcbTargetResolver.Resolve(coverage, inventory);
+
+ Assert.False(resolution.HasTargets);
+ Assert.Empty(resolution.ExactLiveReportControlReferences);
+ Assert.Equal("unresolved", resolution.Segments.Single().Resolution);
+ Assert.Contains(
+ resolution.Warnings,
+ warning => warning.Contains("No sibling/name-family guess", StringComparison.OrdinalIgnoreCase));
+ }
+
+ private static CanonicalStaticReportCoveragePlan Coverage(
+ string dataSet,
+ CanonicalReportControl configured)
+ => new()
+ {
+ Segments = new[]
+ {
+ new CanonicalStaticReportCoverageSegment
+ {
+ DataSetReference = dataSet,
+ SourceDataSetReference = dataSet,
+ SelectedSignalReferences = new[] { "LD0/XCBR1.Pos.stVal" },
+ OrderedMembers = new[]
+ {
+ new CanonicalDataSetMember(
+ 0,
+ "LD0/XCBR1.Pos",
+ "ST",
+ new CanonicalProvenance(CanonicalEvidenceSource.LiveMms, CanonicalConfidence.Exact))
+ },
+ ReportControls = new[] { configured }
+ }
+ }
+ };
+
+ private static MmsReportInventory Inventory(params MmsReportControlCandidate[] controls)
+ {
+ var inventory = new MmsReportInventory();
+ inventory.ReportControls.AddRange(controls);
+ return inventory;
+ }
+
+ private static MmsReportControlCandidate Candidate(
+ string reference,
+ string dataSet,
+ bool buffered)
+ => new()
+ {
+ Reference = reference,
+ DataSetReference = dataSet,
+ Buffered = buffered
+ };
+}
diff --git a/tests/AR.Iec61850.Tests/Mms/MmsRcbAvailabilityTargetSelectorTests.cs b/tests/AR.Iec61850.Tests/Mms/MmsRcbAvailabilityTargetSelectorTests.cs
new file mode 100644
index 00000000..cd5ed253
--- /dev/null
+++ b/tests/AR.Iec61850.Tests/Mms/MmsRcbAvailabilityTargetSelectorTests.cs
@@ -0,0 +1,123 @@
+using AR.Iec61850.Mms;
+
+namespace AR.Iec61850.Tests.Mms;
+
+public sealed class MmsRcbAvailabilityTargetSelectorTests
+{
+ [Fact]
+ public void Empty_Target_Set_Preserves_Broad_Diagnostic_Mode()
+ {
+ var inventory = Inventory(
+ Candidate("LD0/LLN0.BR.B01", buffered: true),
+ Candidate("LD0/LLN0.RP.U01", buffered: false));
+
+ var selection = MmsRcbAvailabilityTargetSelector.Select(
+ inventory,
+ new MmsRcbAvailabilityOptions());
+
+ Assert.False(selection.TargetFilterApplied);
+ Assert.Equal(2, selection.Candidates.Count);
+ Assert.Empty(selection.Warnings);
+ }
+
+ [Fact]
+ public void Exact_Targets_Probe_Only_Selected_Live_Rcbs()
+ {
+ var inventory = Inventory(
+ Candidate("LD0/LLN0.BR.B01", buffered: true),
+ Candidate("LD0/LLN0.BR.B02", buffered: true),
+ Candidate("LD0/LLN0.RP.U01", buffered: false));
+
+ var selection = MmsRcbAvailabilityTargetSelector.Select(
+ inventory,
+ new MmsRcbAvailabilityOptions
+ {
+ TargetReportControlReferences = new HashSet(StringComparer.OrdinalIgnoreCase)
+ {
+ "LD0/LLN0$BR$B02",
+ "LD0/LLN0.RP.U01"
+ }
+ });
+
+ Assert.True(selection.TargetFilterApplied);
+ Assert.Equal(2, selection.RequestedTargetCount);
+ Assert.Equal(2, selection.MatchedTargetCount);
+ Assert.Equal(
+ new[] { "LD0/LLN0.BR.B02", "LD0/LLN0.RP.U01" },
+ selection.Candidates.Select(candidate => candidate.Reference).OrderBy(x => x, StringComparer.Ordinal).ToArray());
+ }
+
+ [Fact]
+ public void Missing_Target_Is_Not_Broadened_To_Sibling_Rcb()
+ {
+ var inventory = Inventory(
+ Candidate("LD0/LLN0.BR.Rpt01", buffered: true),
+ Candidate("LD0/LLN0.BR.Rpt02", buffered: true));
+
+ var selection = MmsRcbAvailabilityTargetSelector.Select(
+ inventory,
+ new MmsRcbAvailabilityOptions
+ {
+ TargetReportControlReferences = new HashSet(StringComparer.OrdinalIgnoreCase)
+ {
+ "LD0/LLN0.BR.Rpt03"
+ }
+ });
+
+ Assert.Empty(selection.Candidates);
+ Assert.Equal(0, selection.MatchedTargetCount);
+ Assert.Contains(
+ selection.Warnings,
+ warning => warning.Contains("not broadened", StringComparison.OrdinalIgnoreCase));
+ }
+
+ [Fact]
+ public void Targeted_Mode_Still_Respects_Hard_Maximum()
+ {
+ var inventory = Inventory(
+ Candidate("LD0/LLN0.BR.B01", buffered: true),
+ Candidate("LD0/LLN0.BR.B02", buffered: true),
+ Candidate("LD0/LLN0.BR.B03", buffered: true));
+
+ var targets = inventory.ReportControls
+ .Select(candidate => candidate.Reference)
+ .ToHashSet(StringComparer.OrdinalIgnoreCase);
+
+ var selection = MmsRcbAvailabilityTargetSelector.Select(
+ inventory,
+ new MmsRcbAvailabilityOptions
+ {
+ MaxReportControls = 2,
+ TargetReportControlReferences = targets
+ });
+
+ Assert.Equal(3, selection.EligibleCountBeforeLimit);
+ Assert.Equal(2, selection.Candidates.Count);
+ Assert.Contains(
+ selection.Warnings,
+ warning => warning.Contains("bounded to 2 of 3", StringComparison.OrdinalIgnoreCase));
+ }
+
+ private static MmsReportInventory Inventory(params MmsReportControlCandidate[] candidates)
+ {
+ var inventory = new MmsReportInventory();
+ inventory.ReportControls.AddRange(candidates);
+ return inventory;
+ }
+
+ private static MmsReportControlCandidate Candidate(string reference, bool buffered)
+ {
+ var slash = reference.IndexOf('/');
+ var tail = reference[(slash + 1)..].Split('.');
+ return new MmsReportControlCandidate
+ {
+ Domain = reference[..slash],
+ LogicalNode = tail[0],
+ FunctionalConstraint = tail[1],
+ Name = tail[2],
+ Reference = reference,
+ Buffered = buffered,
+ DataSetReference = "LD0/LLN0.Events"
+ };
+ }
+}