diff --git a/src/AR.Iec61850/Mms/MmsRcbAvailability.cs b/src/AR.Iec61850/Mms/MmsRcbAvailability.cs index ec3073b2..6fcddeae 100644 --- a/src/AR.Iec61850/Mms/MmsRcbAvailability.cs +++ b/src/AR.Iec61850/Mms/MmsRcbAvailability.cs @@ -44,7 +44,9 @@ public sealed class MmsRcbAvailabilitySnapshot public string Owner { get; init; } = string.Empty; public bool DataSetDirectoryRead { get; init; } public bool DataSetDirectorySuccess { get; init; } + public bool? DataSetIsDeletable { get; init; } public int DataSetMemberCount { get; init; } + public IReadOnlyList DataSetMembers { get; init; } = Array.Empty(); public MmsRcbOperationalAvailability Availability { get; init; } public MmsRcbAvailabilityConfidence Confidence { get; init; } = MmsRcbAvailabilityConfidence.Unknown; public string Reason { get; init; } = string.Empty; @@ -161,7 +163,9 @@ public static MmsRcbAvailabilitySnapshot Evaluate( Owner = candidate.Owner, DataSetDirectoryRead = directoryRead, DataSetDirectorySuccess = directorySuccess, + DataSetIsDeletable = dataSetDirectory?.IsDeletable, DataSetMemberCount = memberCount, + DataSetMembers = dataSetDirectory?.Members.ToArray() ?? Array.Empty(), Availability = availability, Confidence = confidence, Reason = reason, diff --git a/src/AR.Iec61850/Scl/Export/LiveRcbDataSetEvidenceMerger.cs b/src/AR.Iec61850/Scl/Export/LiveRcbDataSetEvidenceMerger.cs new file mode 100644 index 00000000..18203576 --- /dev/null +++ b/src/AR.Iec61850/Scl/Export/LiveRcbDataSetEvidenceMerger.cs @@ -0,0 +1,129 @@ +using AR.Iec61850.Discovery; +using AR.Iec61850.Mms; + +namespace AR.Iec61850.Scl.Export; + +/// +/// Reconciles a live discovery model with the exact DataSet directory read during +/// the passive RCB availability check. Some IEDs expose a DataSet count during the +/// initial scan but only return the FCDA member references through an explicit +/// GetDataSetDirectory request. The source model is never mutated. +/// +public static class LiveRcbDataSetEvidenceMerger +{ + public static LiveIedModelDiscoveryDocument MergeSelectedDataSetDirectory( + LiveIedModelDiscoveryDocument source, + string selectedReportControlReference, + MmsRcbAvailabilityResult availability) + { + ArgumentNullException.ThrowIfNull(source); + ArgumentException.ThrowIfNullOrWhiteSpace(selectedReportControlReference); + ArgumentNullException.ThrowIfNull(availability); + + var selectedReference = Normalize(selectedReportControlReference); + var selected = source.ReportControls.FirstOrDefault(control => + Normalize(control.Reference).Equals(selectedReference, StringComparison.OrdinalIgnoreCase)); + if (selected is null) + throw new InvalidOperationException($"ReportControl '{selectedReportControlReference}' was not found in the live model."); + + var evidence = availability.ReportControls.FirstOrDefault(snapshot => + Normalize(snapshot.Reference).Equals(selectedReference, StringComparison.OrdinalIgnoreCase)); + if (evidence is null) + throw new InvalidOperationException( + $"No live DataSet directory evidence is available for '{selectedReportControlReference}'. Run Check Availability before exporting this RCB."); + if (!evidence.DataSetDirectorySuccess) + throw new InvalidOperationException( + $"The DataSet directory for '{selectedReportControlReference}' was not read successfully. Run Check Availability again before export."); + if (evidence.DataSetMembers.Count == 0) + throw new InvalidOperationException( + $"The live DataSet directory for '{selectedReportControlReference}' contains no exportable members."); + + var dataSetReference = FirstNonEmpty(evidence.DataSetReference, selected.DataSetReference); + var normalizedDataSet = Normalize(dataSetReference); + var sourceDataSet = source.DataSets.FirstOrDefault(dataSet => + Normalize(dataSet.Reference).Equals(normalizedDataSet, StringComparison.OrdinalIgnoreCase)); + if (sourceDataSet is null) + throw new InvalidOperationException( + $"The live model does not contain DataSet '{dataSetReference}' referenced by '{selectedReportControlReference}'."); + + var members = evidence.DataSetMembers + .Select((member, index) => new LiveIedDataSetMemberModel + { + Index = index, + Reference = FirstNonEmpty(member.UserReference, member.MmsReference), + FunctionalConstraint = member.FunctionalConstraint, + MmsReference = member.MmsReference, + Confidence = member.Confidence >= 90 + ? LiveIedDiscoveryConfidenceLevel.Exact + : LiveIedDiscoveryConfidenceLevel.High + }) + .Where(member => !string.IsNullOrWhiteSpace(member.Reference)) + .GroupBy( + member => $"{Normalize(member.Reference)}|{member.FunctionalConstraint}", + StringComparer.OrdinalIgnoreCase) + .Select(group => group.First()) + .Select((member, index) => new LiveIedDataSetMemberModel + { + Index = index, + Reference = member.Reference, + FunctionalConstraint = member.FunctionalConstraint, + MmsReference = member.MmsReference, + Confidence = member.Confidence + }) + .ToArray(); + if (members.Length == 0) + throw new InvalidOperationException( + $"The live DataSet directory for '{selectedReportControlReference}' did not contain usable member references."); + + var mergedDataSet = new LiveIedDataSetModel + { + Reference = sourceDataSet.Reference, + Domain = sourceDataSet.Domain, + LogicalNode = sourceDataSet.LogicalNode, + Name = sourceDataSet.Name, + IsDeletable = evidence.DataSetIsDeletable ?? sourceDataSet.IsDeletable, + MemberCount = members.Length, + Members = members, + UsedByReportControls = sourceDataSet.UsedByReportControls, + UsedByGooseControls = sourceDataSet.UsedByGooseControls, + UsedBySampledValueControls = sourceDataSet.UsedBySampledValueControls + }; + + var mergedDataSets = source.DataSets + .Select(dataSet => Normalize(dataSet.Reference).Equals(normalizedDataSet, StringComparison.OrdinalIgnoreCase) + ? mergedDataSet + : dataSet) + .ToArray(); + + return new LiveIedModelDiscoveryDocument + { + SchemaVersion = source.SchemaVersion, + GeneratedAtUtc = source.GeneratedAtUtc, + Source = source.Source, + Host = source.Host, + Port = source.Port, + IedName = source.IedName, + IedIdentity = source.IedIdentity, + AccessPointName = source.AccessPointName, + Summary = $"{source.Summary} Live DataSet directory evidence merged for selected-RCB export.", + Coverage = source.Coverage, + LogicalDevices = source.LogicalDevices, + FileDirectory = source.FileDirectory, + DataSets = mergedDataSets, + ReportControls = source.ReportControls, + GooseControlBlocks = source.GooseControlBlocks, + SampledValueControlBlocks = source.SampledValueControlBlocks, + SettingGroupControls = source.SettingGroupControls, + LogControls = source.LogControls, + TypeTemplates = source.TypeTemplates, + VariableTypeDiscoveries = source.VariableTypeDiscoveries, + Warnings = source.Warnings + }; + } + + private static string Normalize(string? reference) + => (reference ?? string.Empty).Trim().Replace('$', '.'); + + private static string FirstNonEmpty(params string?[] values) + => values.FirstOrDefault(value => !string.IsNullOrWhiteSpace(value))?.Trim() ?? string.Empty; +} diff --git a/tests/AR.Iec61850.Tests/Scl/LiveRcbDataSetEvidenceMergerTests.cs b/tests/AR.Iec61850.Tests/Scl/LiveRcbDataSetEvidenceMergerTests.cs new file mode 100644 index 00000000..e1c202d6 --- /dev/null +++ b/tests/AR.Iec61850.Tests/Scl/LiveRcbDataSetEvidenceMergerTests.cs @@ -0,0 +1,118 @@ +using AR.Iec61850.Discovery; +using AR.Iec61850.Mms; +using AR.Iec61850.Scl.Export; + +namespace AR.Iec61850.Tests.Scl; + +public sealed class LiveRcbDataSetEvidenceMergerTests +{ + [Fact] + public void MergeSelectedDataSetDirectory_Replaces_Incomplete_Members_Without_Mutating_Source() + { + var dataSet = new LiveIedDataSetModel + { + Reference = "IED1LD0/LLN0.DataSet", + Domain = "IED1LD0", + LogicalNode = "LLN0", + Name = "DataSet", + MemberCount = 1, + Members = Array.Empty() + }; + var reportControl = new LiveIedReportControlModel + { + Reference = "IED1LD0/LLN0.RP.URCB01", + Domain = "IED1LD0", + LogicalNode = "LLN0", + Name = "URCB01", + Buffered = false, + DataSetReference = dataSet.Reference + }; + var source = new LiveIedModelDiscoveryDocument + { + IedName = "IED1", + DataSets = new[] { dataSet }, + ReportControls = new[] { reportControl }, + Coverage = new LiveIedModelDiscoveryCoverage + { + DataSetCount = 1, + ReportControlCount = 1, + UnbufferedReportControlCount = 1 + } + }; + var availability = new MmsRcbAvailabilityResult + { + ReportControls = new[] + { + new MmsRcbAvailabilitySnapshot + { + Reference = reportControl.Reference, + DataSetReference = dataSet.Reference, + DataSetDirectoryRead = true, + DataSetDirectorySuccess = true, + DataSetMemberCount = 1, + DataSetMembers = new[] + { + new MmsDataSetDirectoryMember + { + Domain = "IED1LD0", + MmsItemName = "XCBR1$ST$Pos$stVal", + UserReference = "IED1LD0/XCBR1.Pos.stVal", + FunctionalConstraint = "ST" + } + } + } + } + }; + + var merged = LiveRcbDataSetEvidenceMerger.MergeSelectedDataSetDirectory( + source, + reportControl.Reference, + availability); + var filtered = SclReportControlFilter.FilterLiveModel(merged, reportControl.Reference); + + var mergedDataSet = Assert.Single(filtered.DataSets); + var member = Assert.Single(mergedDataSet.Members); + Assert.Equal("IED1LD0/XCBR1.Pos.stVal", member.Reference); + Assert.Equal("ST", member.FunctionalConstraint); + Assert.Empty(source.DataSets[0].Members); + Assert.Single(filtered.ReportControls); + } + + [Fact] + public void MergeSelectedDataSetDirectory_Requires_Check_Availability_Evidence() + { + var source = new LiveIedModelDiscoveryDocument + { + DataSets = new[] + { + new LiveIedDataSetModel + { + Reference = "IED1LD0/LLN0.DataSet", + Domain = "IED1LD0", + LogicalNode = "LLN0", + Name = "DataSet", + MemberCount = 1 + } + }, + ReportControls = new[] + { + new LiveIedReportControlModel + { + Reference = "IED1LD0/LLN0.RP.URCB01", + Domain = "IED1LD0", + LogicalNode = "LLN0", + Name = "URCB01", + DataSetReference = "IED1LD0/LLN0.DataSet" + } + } + }; + + var error = Assert.Throws(() => + LiveRcbDataSetEvidenceMerger.MergeSelectedDataSetDirectory( + source, + "IED1LD0/LLN0.RP.URCB01", + new MmsRcbAvailabilityResult())); + + Assert.Contains("Run Check Availability", error.Message, StringComparison.OrdinalIgnoreCase); + } +}