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