From 2ae3f112911d29277c0fc694d0c3aa243bb50db3 Mon Sep 17 00:00:00 2001 From: masarray Date: Mon, 20 Jul 2026 05:36:09 +0700 Subject: [PATCH 01/14] Add temporary source snapshot workflow --- .github/workflows/source-snapshot-temp.yml | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 .github/workflows/source-snapshot-temp.yml diff --git a/.github/workflows/source-snapshot-temp.yml b/.github/workflows/source-snapshot-temp.yml new file mode 100644 index 00000000..eaaaacab --- /dev/null +++ b/.github/workflows/source-snapshot-temp.yml @@ -0,0 +1,20 @@ +name: Temporary source snapshot + +on: + push: + branches: [ fix/live-rcb-dataset-export ] + +permissions: + contents: read + +jobs: + snapshot: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/upload-artifact@v4 + with: + name: ariec61850-source-snapshot + path: . + include-hidden-files: true + retention-days: 1 From b5cfd8c16dc88cdda0ebd8b17c3e2f511a01dec0 Mon Sep 17 00:00:00 2001 From: masarray Date: Mon, 20 Jul 2026 05:36:45 +0700 Subject: [PATCH 02/14] Run temporary source snapshot on pull requests --- .github/workflows/source-snapshot-temp.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/source-snapshot-temp.yml b/.github/workflows/source-snapshot-temp.yml index eaaaacab..30560ae4 100644 --- a/.github/workflows/source-snapshot-temp.yml +++ b/.github/workflows/source-snapshot-temp.yml @@ -3,6 +3,8 @@ name: Temporary source snapshot on: push: branches: [ fix/live-rcb-dataset-export ] + pull_request: + branches: [ main ] permissions: contents: read From c049fdca2c57158051304e2a5e3abd294691d5a1 Mon Sep 17 00:00:00 2001 From: masarray Date: Mon, 20 Jul 2026 05:44:22 +0700 Subject: [PATCH 03/14] Add one-time live RCB DataSet fix applicator --- .../apply-live-rcb-dataset-fix-temp.yml | 381 ++++++++++++++++++ 1 file changed, 381 insertions(+) create mode 100644 .github/workflows/apply-live-rcb-dataset-fix-temp.yml diff --git a/.github/workflows/apply-live-rcb-dataset-fix-temp.yml b/.github/workflows/apply-live-rcb-dataset-fix-temp.yml new file mode 100644 index 00000000..518d843d --- /dev/null +++ b/.github/workflows/apply-live-rcb-dataset-fix-temp.yml @@ -0,0 +1,381 @@ +name: Temporary apply live RCB DataSet export fix + +on: + push: + branches: [ fix/live-rcb-dataset-export ] + +permissions: + contents: write + +jobs: + patch-and-test: + if: github.actor != 'github-actions[bot]' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + ref: fix/live-rcb-dataset-export + fetch-depth: 0 + + - name: Apply engine fix and regression tests + shell: python + run: | + from pathlib import Path + + availability_path = Path('src/AR.Iec61850/Mms/MmsRcbAvailability.cs') + text = availability_path.read_text(encoding='utf-8') + old = ''' public bool DataSetDirectoryRead { get; init; } + public bool DataSetDirectorySuccess { get; init; } + public int DataSetMemberCount { get; init; } + '''.replace(' ', ' ') + new = ''' 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(); + '''.replace(' ', ' ') + if 'DataSetMembers { get; init; }' not in text: + if old not in text: + raise SystemExit('MmsRcbAvailabilitySnapshot anchor not found') + text = text.replace(old, new, 1) + old = ''' DataSetDirectoryRead = directoryRead, + DataSetDirectorySuccess = directorySuccess, + DataSetMemberCount = memberCount, + '''.replace(' ', ' ') + new = ''' DataSetDirectoryRead = directoryRead, + DataSetDirectorySuccess = directorySuccess, + DataSetIsDeletable = dataSetDirectory?.IsDeletable, + DataSetMemberCount = memberCount, + DataSetMembers = dataSetDirectory?.Members.ToArray() ?? Array.Empty(), + '''.replace(' ', ' ') + if 'DataSetIsDeletable = dataSetDirectory?.IsDeletable' not in text: + if old not in text: + raise SystemExit('availability result initializer anchor not found') + text = text.replace(old, new, 1) + availability_path.write_text(text, encoding='utf-8') + + filter_path = Path('src/AR.Iec61850/Scl/Export/SclReportControlFilter.cs') + text = filter_path.read_text(encoding='utf-8') + anchor = ''' public static LiveIedModelDiscoveryDocument FilterLiveModel( + LiveIedModelDiscoveryDocument source, + string selectedReportControlReference) + { + '''.replace(' ', ' ') + implementation = ''' public static LiveIedModelDiscoveryDocument FilterLiveModel( + LiveIedModelDiscoveryDocument source, + string selectedReportControlReference, + MmsRcbAvailabilityResult availability) + { + ArgumentNullException.ThrowIfNull(availability); + return FilterLiveModel( + MergeLiveDataSetEvidence(source, availability.ReportControls), + selectedReportControlReference); + } + + /// + /// Merges the exact DataSet directory returned by the read-only RCB availability + /// audit into a cloned live discovery document. Some relays expose the DataSet + /// member count during discovery but only return the actual member references when + /// GetDataSetDirectory is requested explicitly. The source model is never mutated. + /// + public static LiveIedModelDiscoveryDocument MergeLiveDataSetEvidence( + LiveIedModelDiscoveryDocument source, + IEnumerable availabilitySnapshots) + { + ArgumentNullException.ThrowIfNull(source); + ArgumentNullException.ThrowIfNull(availabilitySnapshots); + + var evidenceByReference = availabilitySnapshots + .Where(snapshot => snapshot.DataSetDirectorySuccess && + !string.IsNullOrWhiteSpace(snapshot.DataSetReference) && + snapshot.DataSetMembers.Count > 0) + .GroupBy(snapshot => NormalizeReference(snapshot.DataSetReference), StringComparer.OrdinalIgnoreCase) + .ToDictionary(group => group.Key, group => group.First(), StringComparer.OrdinalIgnoreCase); + + if (evidenceByReference.Count == 0) + return source; + + var mergedDataSets = new List(); + var mergedReferences = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var dataSet in source.DataSets) + { + var normalized = NormalizeReference(dataSet.Reference); + if (!evidenceByReference.TryGetValue(normalized, out var evidence)) + { + mergedDataSets.Add(dataSet); + mergedReferences.Add(normalized); + continue; + } + + mergedDataSets.Add(MergeDataSet(dataSet, evidence)); + mergedReferences.Add(normalized); + } + + foreach (var pair in evidenceByReference) + { + if (mergedReferences.Contains(pair.Key)) + continue; + mergedDataSets.Add(CreateDataSetFromEvidence(source, pair.Value)); + } + + var coverage = source.Coverage; + var mergedCoverage = new LiveIedModelDiscoveryCoverage + { + LogicalDeviceCount = coverage.LogicalDeviceCount, + LogicalNodeCount = coverage.LogicalNodeCount, + DataObjectCount = coverage.DataObjectCount, + DataAttributeCount = coverage.DataAttributeCount, + ExactFunctionalConstraintCount = coverage.ExactFunctionalConstraintCount, + HighConfidenceCdcCount = coverage.HighConfidenceCdcCount, + MediumConfidenceCdcCount = coverage.MediumConfidenceCdcCount, + LowConfidenceCdcCount = coverage.LowConfidenceCdcCount, + UnknownCdcCount = coverage.UnknownCdcCount, + DataSetCount = mergedDataSets.Count, + FileCount = coverage.FileCount, + VariableTypeReadAttemptCount = coverage.VariableTypeReadAttemptCount, + VariableTypeReadSuccessCount = coverage.VariableTypeReadSuccessCount, + VariableTypeReadFailureCount = coverage.VariableTypeReadFailureCount, + ExactMmsTypeCount = coverage.ExactMmsTypeCount, + ReportControlCount = coverage.ReportControlCount, + BufferedReportControlCount = coverage.BufferedReportControlCount, + UnbufferedReportControlCount = coverage.UnbufferedReportControlCount, + GooseControlBlockCount = coverage.GooseControlBlockCount, + SampledValueControlBlockCount = coverage.SampledValueControlBlockCount, + SettingGroupControlCount = coverage.SettingGroupControlCount, + LogControlCount = coverage.LogControlCount + }; + + 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 = mergedCoverage, + LogicalDevices = source.LogicalDevices, + FileDirectory = source.FileDirectory, + DataSets = mergedDataSets.OrderBy(item => item.Reference, StringComparer.OrdinalIgnoreCase).ToArray(), + 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 LiveIedDataSetModel MergeDataSet( + LiveIedDataSetModel source, + MmsRcbAvailabilitySnapshot evidence) + { + var members = ConvertDataSetMembers(evidence); + return new LiveIedDataSetModel + { + Reference = source.Reference, + Domain = source.Domain, + LogicalNode = source.LogicalNode, + Name = source.Name, + IsDeletable = evidence.DataSetIsDeletable ?? source.IsDeletable, + MemberCount = members.Count, + Members = members, + UsedByReportControls = source.UsedByReportControls, + UsedByGooseControls = source.UsedByGooseControls, + UsedBySampledValueControls = source.UsedBySampledValueControls + }; + } + + private static LiveIedDataSetModel CreateDataSetFromEvidence( + LiveIedModelDiscoveryDocument source, + MmsRcbAvailabilitySnapshot evidence) + { + var (domain, logicalNode, name) = ParseDataSetReference(evidence.DataSetReference); + var usedByReports = source.ReportControls + .Where(control => NormalizeReference(control.DataSetReference) + .Equals(NormalizeReference(evidence.DataSetReference), StringComparison.OrdinalIgnoreCase)) + .Select(control => control.Reference) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray(); + var members = ConvertDataSetMembers(evidence); + return new LiveIedDataSetModel + { + Reference = evidence.DataSetReference, + Domain = domain, + LogicalNode = logicalNode, + Name = name, + IsDeletable = evidence.DataSetIsDeletable, + MemberCount = members.Count, + Members = members, + UsedByReportControls = usedByReports + }; + } + + private static IReadOnlyList ConvertDataSetMembers( + MmsRcbAvailabilitySnapshot evidence) + => 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 => $"{NormalizeReference(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(); + + private static (string Domain, string LogicalNode, string Name) ParseDataSetReference(string reference) + { + var normalized = NormalizeReference(reference); + var slash = normalized.IndexOf('/'); + var dot = normalized.LastIndexOf('.'); + if (slash <= 0 || dot <= slash || dot >= normalized.Length - 1) + throw new InvalidOperationException($"DataSet reference '{reference}' cannot be mapped into the live SCL model."); + return (normalized[..slash], normalized[(slash + 1)..dot], normalized[(dot + 1)..]); + } + + private static string FirstNonEmpty(params string?[] values) + => values.FirstOrDefault(value => !string.IsNullOrWhiteSpace(value))?.Trim() ?? string.Empty; + + '''.replace(' ', ' ') + if 'MergeLiveDataSetEvidence(' not in text: + if anchor not in text: + raise SystemExit('FilterLiveModel anchor not found') + text = text.replace(anchor, implementation + anchor, 1) + filter_path.write_text(text, encoding='utf-8') + + availability_tests = Path('tests/AR.Iec61850.Tests/Mms/MmsRcbAvailabilityTests.cs') + text = availability_tests.read_text(encoding='utf-8') + old = ''' Assert.True(result.IsSelectable); + Assert.Equal(2, result.DataSetMemberCount); + '''.replace(' ', ' ') + new = ''' Assert.True(result.IsSelectable); + Assert.Equal(2, result.DataSetMemberCount); + Assert.Equal(2, result.DataSetMembers.Count); + Assert.Equal("IED1LD0/XCBR1.Pos.stVal", result.DataSetMembers[0].UserReference); + '''.replace(' ', ' ') + if 'Assert.Equal(2, result.DataSetMembers.Count);' not in text: + if old not in text: + raise SystemExit('availability test anchor not found') + text = text.replace(old, new, 1) + availability_tests.write_text(text, encoding='utf-8') + + filter_tests = Path('tests/AR.Iec61850.Tests/Scl/SclReportControlFilterTests.cs') + text = filter_tests.read_text(encoding='utf-8') + if 'using AR.Iec61850.Mms;' not in text: + text = text.replace('using AR.Iec61850.Discovery;\n', 'using AR.Iec61850.Discovery;\nusing AR.Iec61850.Mms;\n', 1) + test = ''' + [Fact] + public void FilterLiveModel_Merges_ReadOnly_DataSet_Directory_Evidence_Before_Export() + { + var dataSet = new LiveIedDataSetModel + { + Reference = "IED1LD0/LLN0.DataSet", + Domain = "IED1LD0", + LogicalNode = "LLN0", + Name = "DataSet", + MemberCount = 1, + Members = Array.Empty() + }; + var selected = 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[] { selected }, + Coverage = new LiveIedModelDiscoveryCoverage + { + ReportControlCount = 1, + UnbufferedReportControlCount = 1, + DataSetCount = 1 + } + }; + var availability = new MmsRcbAvailabilityResult + { + ReportControls = new[] + { + new MmsRcbAvailabilitySnapshot + { + Reference = selected.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 filtered = SclReportControlFilter.FilterLiveModel(source, selected.Reference, availability); + + 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); + } + + '''.replace(' ', ' ') + if 'FilterLiveModel_Merges_ReadOnly_DataSet_Directory_Evidence_Before_Export' not in text: + marker = ' private static string Fixture()\n' + if marker not in text: + raise SystemExit('filter test anchor not found') + text = text.replace(marker, test + marker, 1) + filter_tests.write_text(text, encoding='utf-8') + + - uses: actions/setup-dotnet@v4 + with: + dotnet-version: 8.0.x + + - name: Restore and test + run: | + dotnet restore ARIEC61850.sln + dotnet test ARIEC61850.sln -c Release --no-restore + + - name: Commit tested fix + shell: bash + run: | + if git diff --quiet -- src tests; then + exit 0 + fi + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add src tests + git commit -m "Preserve live DataSet members for selected-RCB export" + git push origin HEAD:fix/live-rcb-dataset-export From bfa346b82498d1a854c559fd631c0f0894bb2cd3 Mon Sep 17 00:00:00 2001 From: masarray Date: Mon, 20 Jul 2026 05:45:30 +0700 Subject: [PATCH 04/14] Trigger tested live RCB DataSet patch --- .github/workflows/source-snapshot-temp.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/source-snapshot-temp.yml b/.github/workflows/source-snapshot-temp.yml index 30560ae4..bb5db9b2 100644 --- a/.github/workflows/source-snapshot-temp.yml +++ b/.github/workflows/source-snapshot-temp.yml @@ -20,3 +20,4 @@ jobs: path: . include-hidden-files: true retention-days: 1 + # A second user-authored push activates the one-time patch workflow. From 3461f5d20c5657a00c7e7f0f2765b37d962a08b8 Mon Sep 17 00:00:00 2001 From: masarray Date: Mon, 20 Jul 2026 05:49:21 +0700 Subject: [PATCH 05/14] Add deterministic one-time RCB export patch runner --- .../apply-live-rcb-patch-v2-temp.yml | 49 +++++++++++++++++++ 1 file changed, 49 insertions(+) create mode 100644 .github/workflows/apply-live-rcb-patch-v2-temp.yml diff --git a/.github/workflows/apply-live-rcb-patch-v2-temp.yml b/.github/workflows/apply-live-rcb-patch-v2-temp.yml new file mode 100644 index 00000000..f6532e8f --- /dev/null +++ b/.github/workflows/apply-live-rcb-patch-v2-temp.yml @@ -0,0 +1,49 @@ +name: Temporary apply tested live RCB patch v2 + +on: + push: + branches: [ fix/live-rcb-dataset-export ] + +permissions: + contents: write + +jobs: + apply-test-commit: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + ref: fix/live-rcb-dataset-export + fetch-depth: 0 + + - name: Apply patch + shell: bash + run: | + if grep -q "DataSetMembers { get; init; }" src/AR.Iec61850/Mms/MmsRcbAvailability.cs; then + echo "Patch already applied." + else + git apply --check scripts/live-rcb-dataset-export.patch + git apply scripts/live-rcb-dataset-export.patch + fi + + - uses: actions/setup-dotnet@v4 + with: + dotnet-version: 8.0.x + + - name: Restore and run all tests + shell: bash + run: | + dotnet restore ARIEC61850.sln + dotnet test ARIEC61850.sln -c Release --no-restore + + - name: Commit tested source changes + shell: bash + run: | + if git diff --quiet -- src tests; then + exit 0 + fi + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add src tests + git commit -m "Preserve live DataSet members for selected-RCB export" + git push origin HEAD:fix/live-rcb-dataset-export From 27cfebf002eb192020417be3042a34b8f112ff94 Mon Sep 17 00:00:00 2001 From: masarray Date: Mon, 20 Jul 2026 05:50:12 +0700 Subject: [PATCH 06/14] Add tested live RCB DataSet export patch --- scripts/live-rcb-dataset-export.patch | 331 ++++++++++++++++++++++++++ 1 file changed, 331 insertions(+) create mode 100644 scripts/live-rcb-dataset-export.patch diff --git a/scripts/live-rcb-dataset-export.patch b/scripts/live-rcb-dataset-export.patch new file mode 100644 index 00000000..5417fdfc --- /dev/null +++ b/scripts/live-rcb-dataset-export.patch @@ -0,0 +1,331 @@ +diff --git a/src/AR.Iec61850/Mms/MmsRcbAvailability.cs b/src/AR.Iec61850/Mms/MmsRcbAvailability.cs +index ec3073b..6fcddea 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/SclReportControlFilter.cs b/src/AR.Iec61850/Scl/Export/SclReportControlFilter.cs +index 6fa3bd6..dfdd433 100644 +--- a/src/AR.Iec61850/Scl/Export/SclReportControlFilter.cs ++++ b/src/AR.Iec61850/Scl/Export/SclReportControlFilter.cs +@@ -237,6 +237,202 @@ public static SclReportControlFilterResult Filter( + }; + } + ++ public static LiveIedModelDiscoveryDocument FilterLiveModel( ++ LiveIedModelDiscoveryDocument source, ++ string selectedReportControlReference, ++ MmsRcbAvailabilityResult availability) ++ { ++ ArgumentNullException.ThrowIfNull(availability); ++ return FilterLiveModel( ++ MergeLiveDataSetEvidence(source, availability.ReportControls), ++ selectedReportControlReference); ++ } ++ ++ /// ++ /// Merges the exact DataSet directory returned by the read-only RCB availability ++ /// audit into a cloned live discovery document. Some relays expose the DataSet ++ /// member count during discovery but only return the actual member references when ++ /// GetDataSetDirectory is requested explicitly. The source model is never mutated. ++ /// ++ public static LiveIedModelDiscoveryDocument MergeLiveDataSetEvidence( ++ LiveIedModelDiscoveryDocument source, ++ IEnumerable availabilitySnapshots) ++ { ++ ArgumentNullException.ThrowIfNull(source); ++ ArgumentNullException.ThrowIfNull(availabilitySnapshots); ++ ++ var evidenceByReference = availabilitySnapshots ++ .Where(snapshot => snapshot.DataSetDirectorySuccess && ++ !string.IsNullOrWhiteSpace(snapshot.DataSetReference) && ++ snapshot.DataSetMembers.Count > 0) ++ .GroupBy(snapshot => NormalizeReference(snapshot.DataSetReference), StringComparer.OrdinalIgnoreCase) ++ .ToDictionary(group => group.Key, group => group.First(), StringComparer.OrdinalIgnoreCase); ++ ++ if (evidenceByReference.Count == 0) ++ return source; ++ ++ var mergedDataSets = new List(); ++ var mergedReferences = new HashSet(StringComparer.OrdinalIgnoreCase); ++ foreach (var dataSet in source.DataSets) ++ { ++ var normalized = NormalizeReference(dataSet.Reference); ++ if (!evidenceByReference.TryGetValue(normalized, out var evidence)) ++ { ++ mergedDataSets.Add(dataSet); ++ mergedReferences.Add(normalized); ++ continue; ++ } ++ ++ mergedDataSets.Add(MergeDataSet(dataSet, evidence)); ++ mergedReferences.Add(normalized); ++ } ++ ++ foreach (var pair in evidenceByReference) ++ { ++ if (mergedReferences.Contains(pair.Key)) ++ continue; ++ mergedDataSets.Add(CreateDataSetFromEvidence(source, pair.Value)); ++ } ++ ++ var coverage = source.Coverage; ++ var mergedCoverage = new LiveIedModelDiscoveryCoverage ++ { ++ LogicalDeviceCount = coverage.LogicalDeviceCount, ++ LogicalNodeCount = coverage.LogicalNodeCount, ++ DataObjectCount = coverage.DataObjectCount, ++ DataAttributeCount = coverage.DataAttributeCount, ++ ExactFunctionalConstraintCount = coverage.ExactFunctionalConstraintCount, ++ HighConfidenceCdcCount = coverage.HighConfidenceCdcCount, ++ MediumConfidenceCdcCount = coverage.MediumConfidenceCdcCount, ++ LowConfidenceCdcCount = coverage.LowConfidenceCdcCount, ++ UnknownCdcCount = coverage.UnknownCdcCount, ++ DataSetCount = mergedDataSets.Count, ++ FileCount = coverage.FileCount, ++ VariableTypeReadAttemptCount = coverage.VariableTypeReadAttemptCount, ++ VariableTypeReadSuccessCount = coverage.VariableTypeReadSuccessCount, ++ VariableTypeReadFailureCount = coverage.VariableTypeReadFailureCount, ++ ExactMmsTypeCount = coverage.ExactMmsTypeCount, ++ ReportControlCount = coverage.ReportControlCount, ++ BufferedReportControlCount = coverage.BufferedReportControlCount, ++ UnbufferedReportControlCount = coverage.UnbufferedReportControlCount, ++ GooseControlBlockCount = coverage.GooseControlBlockCount, ++ SampledValueControlBlockCount = coverage.SampledValueControlBlockCount, ++ SettingGroupControlCount = coverage.SettingGroupControlCount, ++ LogControlCount = coverage.LogControlCount ++ }; ++ ++ 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 = mergedCoverage, ++ LogicalDevices = source.LogicalDevices, ++ FileDirectory = source.FileDirectory, ++ DataSets = mergedDataSets ++ .OrderBy(item => item.Reference, StringComparer.OrdinalIgnoreCase) ++ .ToArray(), ++ 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 LiveIedDataSetModel MergeDataSet( ++ LiveIedDataSetModel source, ++ MmsRcbAvailabilitySnapshot evidence) ++ { ++ var members = ConvertDataSetMembers(evidence); ++ return new LiveIedDataSetModel ++ { ++ Reference = source.Reference, ++ Domain = source.Domain, ++ LogicalNode = source.LogicalNode, ++ Name = source.Name, ++ IsDeletable = evidence.DataSetIsDeletable ?? source.IsDeletable, ++ MemberCount = members.Count, ++ Members = members, ++ UsedByReportControls = source.UsedByReportControls, ++ UsedByGooseControls = source.UsedByGooseControls, ++ UsedBySampledValueControls = source.UsedBySampledValueControls ++ }; ++ } ++ ++ private static LiveIedDataSetModel CreateDataSetFromEvidence( ++ LiveIedModelDiscoveryDocument source, ++ MmsRcbAvailabilitySnapshot evidence) ++ { ++ var (domain, logicalNode, name) = ParseDataSetReference(evidence.DataSetReference); ++ var usedByReports = source.ReportControls ++ .Where(control => NormalizeReference(control.DataSetReference) ++ .Equals(NormalizeReference(evidence.DataSetReference), StringComparison.OrdinalIgnoreCase)) ++ .Select(control => control.Reference) ++ .Distinct(StringComparer.OrdinalIgnoreCase) ++ .ToArray(); ++ var members = ConvertDataSetMembers(evidence); ++ return new LiveIedDataSetModel ++ { ++ Reference = evidence.DataSetReference, ++ Domain = domain, ++ LogicalNode = logicalNode, ++ Name = name, ++ IsDeletable = evidence.DataSetIsDeletable, ++ MemberCount = members.Count, ++ Members = members, ++ UsedByReportControls = usedByReports ++ }; ++ } ++ ++ private static IReadOnlyList ConvertDataSetMembers( ++ MmsRcbAvailabilitySnapshot evidence) ++ => 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 => $"{NormalizeReference(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(); ++ ++ private static (string Domain, string LogicalNode, string Name) ParseDataSetReference(string reference) ++ { ++ var normalized = NormalizeReference(reference); ++ var slash = normalized.IndexOf('/'); ++ var dot = normalized.LastIndexOf('.'); ++ if (slash <= 0 || dot <= slash || dot >= normalized.Length - 1) ++ throw new InvalidOperationException($"DataSet reference '{reference}' cannot be mapped into the live SCL model."); ++ return (normalized[..slash], normalized[(slash + 1)..dot], normalized[(dot + 1)..]); ++ } ++ ++ private static string FirstNonEmpty(params string?[] values) ++ => values.FirstOrDefault(value => !string.IsNullOrWhiteSpace(value))?.Trim() ?? string.Empty; ++ + public static LiveIedModelDiscoveryDocument FilterLiveModel( + LiveIedModelDiscoveryDocument source, + string selectedReportControlReference) +diff --git a/tests/AR.Iec61850.Tests/Mms/MmsRcbAvailabilityTests.cs b/tests/AR.Iec61850.Tests/Mms/MmsRcbAvailabilityTests.cs +index 0605682..d3559f9 100644 +--- a/tests/AR.Iec61850.Tests/Mms/MmsRcbAvailabilityTests.cs ++++ b/tests/AR.Iec61850.Tests/Mms/MmsRcbAvailabilityTests.cs +@@ -17,6 +17,8 @@ public void Evaluate_Requires_Explicit_Free_Runtime_State_For_Green_Available() + Assert.Equal(MmsRcbAvailabilityConfidence.Exact, result.Confidence); + Assert.True(result.IsSelectable); + Assert.Equal(2, result.DataSetMemberCount); ++ Assert.Equal(2, result.DataSetMembers.Count); ++ Assert.Equal("IED1LD0/XCBR1.Pos.stVal", result.DataSetMembers[0].UserReference); + } + + [Theory] +diff --git a/tests/AR.Iec61850.Tests/Scl/SclReportControlFilterTests.cs b/tests/AR.Iec61850.Tests/Scl/SclReportControlFilterTests.cs +index 49001e2..549231b 100644 +--- a/tests/AR.Iec61850.Tests/Scl/SclReportControlFilterTests.cs ++++ b/tests/AR.Iec61850.Tests/Scl/SclReportControlFilterTests.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; + +@@ -181,6 +182,75 @@ public void FilterLiveModel_Retains_One_Rcb_And_Validates_DataSet() + Assert.Equal(2, source.ReportControls.Count); + } + ++ ++ [Fact] ++ public void FilterLiveModel_Merges_ReadOnly_DataSet_Directory_Evidence_Before_Export() ++ { ++ var dataSet = new LiveIedDataSetModel ++ { ++ Reference = "IED1LD0/LLN0.DataSet", ++ Domain = "IED1LD0", ++ LogicalNode = "LLN0", ++ Name = "DataSet", ++ MemberCount = 1, ++ Members = Array.Empty() ++ }; ++ var selected = 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[] { selected }, ++ Coverage = new LiveIedModelDiscoveryCoverage ++ { ++ ReportControlCount = 1, ++ UnbufferedReportControlCount = 1, ++ DataSetCount = 1 ++ } ++ }; ++ var availability = new MmsRcbAvailabilityResult ++ { ++ ReportControls = new[] ++ { ++ new MmsRcbAvailabilitySnapshot ++ { ++ Reference = selected.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 filtered = SclReportControlFilter.FilterLiveModel(source, selected.Reference, availability); ++ ++ 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); ++ } ++ + private static string Fixture() + => """ + From 391a96c3a54a3a4134bb5a5267efb7ae279cc5f4 Mon Sep 17 00:00:00 2001 From: masarray Date: Mon, 20 Jul 2026 05:53:52 +0700 Subject: [PATCH 07/14] Preserve live DataSet directory members in RCB availability --- src/AR.Iec61850/Mms/MmsRcbAvailability.cs | 4 ++++ 1 file changed, 4 insertions(+) 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, From 3dd3f2a35715e378650ce184c27d9ca7985068ce Mon Sep 17 00:00:00 2001 From: masarray Date: Mon, 20 Jul 2026 05:55:25 +0700 Subject: [PATCH 08/14] Apply and test remaining live RCB export fix --- .github/workflows/source-snapshot-temp.yml | 50 +++++++++++++++++----- 1 file changed, 39 insertions(+), 11 deletions(-) diff --git a/.github/workflows/source-snapshot-temp.yml b/.github/workflows/source-snapshot-temp.yml index bb5db9b2..b4ba8632 100644 --- a/.github/workflows/source-snapshot-temp.yml +++ b/.github/workflows/source-snapshot-temp.yml @@ -1,23 +1,51 @@ -name: Temporary source snapshot +name: Temporary apply remaining live RCB export fix on: push: branches: [ fix/live-rcb-dataset-export ] - pull_request: - branches: [ main ] permissions: - contents: read + contents: write jobs: - snapshot: + patch-test-commit: + if: github.actor != 'github-actions[bot]' runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - uses: actions/upload-artifact@v4 with: - name: ariec61850-source-snapshot - path: . - include-hidden-files: true - retention-days: 1 - # A second user-authored push activates the one-time patch workflow. + ref: fix/live-rcb-dataset-export + fetch-depth: 0 + + - name: Apply filter and regression-test patch + shell: bash + run: | + if ! grep -q "MergeLiveDataSetEvidence" src/AR.Iec61850/Scl/Export/SclReportControlFilter.cs; then + git apply --check --exclude=src/AR.Iec61850/Mms/MmsRcbAvailability.cs scripts/live-rcb-dataset-export.patch + git apply --exclude=src/AR.Iec61850/Mms/MmsRcbAvailability.cs scripts/live-rcb-dataset-export.patch + fi + if ! grep -q "using AR.Iec61850.Mms;" src/AR.Iec61850/Scl/Export/SclReportControlFilter.cs; then + sed -i '/using AR.Iec61850.Discovery;/a using AR.Iec61850.Mms;' src/AR.Iec61850/Scl/Export/SclReportControlFilter.cs + fi + + - uses: actions/setup-dotnet@v4 + with: + dotnet-version: 8.0.x + + - name: Restore and test all engine projects + shell: bash + run: | + dotnet restore ARIEC61850.sln + dotnet test ARIEC61850.sln -c Release --no-restore + + - name: Commit tested source changes + shell: bash + run: | + if git diff --quiet -- src tests; then + exit 0 + fi + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add src tests + git commit -m "Preserve live DataSet members for selected-RCB export" + git push origin HEAD:fix/live-rcb-dataset-export From 184ee71249957f7b07517d927f5f9db13332fb5a Mon Sep 17 00:00:00 2001 From: masarray Date: Mon, 20 Jul 2026 05:57:13 +0700 Subject: [PATCH 09/14] Merge live DataSet evidence before selected-RCB export --- .../Export/LiveRcbDataSetEvidenceMerger.cs | 129 ++++++++++++++++++ 1 file changed, 129 insertions(+) create mode 100644 src/AR.Iec61850/Scl/Export/LiveRcbDataSetEvidenceMerger.cs 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; +} From 34c68492ff17cbd42c0524bcb51d3f6a63c2897f Mon Sep 17 00:00:00 2001 From: masarray Date: Mon, 20 Jul 2026 05:57:38 +0700 Subject: [PATCH 10/14] Test live RCB DataSet evidence merging --- .../Scl/LiveRcbDataSetEvidenceMergerTests.cs | 118 ++++++++++++++++++ 1 file changed, 118 insertions(+) create mode 100644 tests/AR.Iec61850.Tests/Scl/LiveRcbDataSetEvidenceMergerTests.cs 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); + } +} From 2597853d9d5744caf97ba33f9df2a7cd9881dae0 Mon Sep 17 00:00:00 2001 From: masarray Date: Mon, 20 Jul 2026 06:00:58 +0700 Subject: [PATCH 11/14] Remove temporary RCB patch workflow --- .../apply-live-rcb-dataset-fix-temp.yml | 381 ------------------ 1 file changed, 381 deletions(-) delete mode 100644 .github/workflows/apply-live-rcb-dataset-fix-temp.yml diff --git a/.github/workflows/apply-live-rcb-dataset-fix-temp.yml b/.github/workflows/apply-live-rcb-dataset-fix-temp.yml deleted file mode 100644 index 518d843d..00000000 --- a/.github/workflows/apply-live-rcb-dataset-fix-temp.yml +++ /dev/null @@ -1,381 +0,0 @@ -name: Temporary apply live RCB DataSet export fix - -on: - push: - branches: [ fix/live-rcb-dataset-export ] - -permissions: - contents: write - -jobs: - patch-and-test: - if: github.actor != 'github-actions[bot]' - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - with: - ref: fix/live-rcb-dataset-export - fetch-depth: 0 - - - name: Apply engine fix and regression tests - shell: python - run: | - from pathlib import Path - - availability_path = Path('src/AR.Iec61850/Mms/MmsRcbAvailability.cs') - text = availability_path.read_text(encoding='utf-8') - old = ''' public bool DataSetDirectoryRead { get; init; } - public bool DataSetDirectorySuccess { get; init; } - public int DataSetMemberCount { get; init; } - '''.replace(' ', ' ') - new = ''' 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(); - '''.replace(' ', ' ') - if 'DataSetMembers { get; init; }' not in text: - if old not in text: - raise SystemExit('MmsRcbAvailabilitySnapshot anchor not found') - text = text.replace(old, new, 1) - old = ''' DataSetDirectoryRead = directoryRead, - DataSetDirectorySuccess = directorySuccess, - DataSetMemberCount = memberCount, - '''.replace(' ', ' ') - new = ''' DataSetDirectoryRead = directoryRead, - DataSetDirectorySuccess = directorySuccess, - DataSetIsDeletable = dataSetDirectory?.IsDeletable, - DataSetMemberCount = memberCount, - DataSetMembers = dataSetDirectory?.Members.ToArray() ?? Array.Empty(), - '''.replace(' ', ' ') - if 'DataSetIsDeletable = dataSetDirectory?.IsDeletable' not in text: - if old not in text: - raise SystemExit('availability result initializer anchor not found') - text = text.replace(old, new, 1) - availability_path.write_text(text, encoding='utf-8') - - filter_path = Path('src/AR.Iec61850/Scl/Export/SclReportControlFilter.cs') - text = filter_path.read_text(encoding='utf-8') - anchor = ''' public static LiveIedModelDiscoveryDocument FilterLiveModel( - LiveIedModelDiscoveryDocument source, - string selectedReportControlReference) - { - '''.replace(' ', ' ') - implementation = ''' public static LiveIedModelDiscoveryDocument FilterLiveModel( - LiveIedModelDiscoveryDocument source, - string selectedReportControlReference, - MmsRcbAvailabilityResult availability) - { - ArgumentNullException.ThrowIfNull(availability); - return FilterLiveModel( - MergeLiveDataSetEvidence(source, availability.ReportControls), - selectedReportControlReference); - } - - /// - /// Merges the exact DataSet directory returned by the read-only RCB availability - /// audit into a cloned live discovery document. Some relays expose the DataSet - /// member count during discovery but only return the actual member references when - /// GetDataSetDirectory is requested explicitly. The source model is never mutated. - /// - public static LiveIedModelDiscoveryDocument MergeLiveDataSetEvidence( - LiveIedModelDiscoveryDocument source, - IEnumerable availabilitySnapshots) - { - ArgumentNullException.ThrowIfNull(source); - ArgumentNullException.ThrowIfNull(availabilitySnapshots); - - var evidenceByReference = availabilitySnapshots - .Where(snapshot => snapshot.DataSetDirectorySuccess && - !string.IsNullOrWhiteSpace(snapshot.DataSetReference) && - snapshot.DataSetMembers.Count > 0) - .GroupBy(snapshot => NormalizeReference(snapshot.DataSetReference), StringComparer.OrdinalIgnoreCase) - .ToDictionary(group => group.Key, group => group.First(), StringComparer.OrdinalIgnoreCase); - - if (evidenceByReference.Count == 0) - return source; - - var mergedDataSets = new List(); - var mergedReferences = new HashSet(StringComparer.OrdinalIgnoreCase); - foreach (var dataSet in source.DataSets) - { - var normalized = NormalizeReference(dataSet.Reference); - if (!evidenceByReference.TryGetValue(normalized, out var evidence)) - { - mergedDataSets.Add(dataSet); - mergedReferences.Add(normalized); - continue; - } - - mergedDataSets.Add(MergeDataSet(dataSet, evidence)); - mergedReferences.Add(normalized); - } - - foreach (var pair in evidenceByReference) - { - if (mergedReferences.Contains(pair.Key)) - continue; - mergedDataSets.Add(CreateDataSetFromEvidence(source, pair.Value)); - } - - var coverage = source.Coverage; - var mergedCoverage = new LiveIedModelDiscoveryCoverage - { - LogicalDeviceCount = coverage.LogicalDeviceCount, - LogicalNodeCount = coverage.LogicalNodeCount, - DataObjectCount = coverage.DataObjectCount, - DataAttributeCount = coverage.DataAttributeCount, - ExactFunctionalConstraintCount = coverage.ExactFunctionalConstraintCount, - HighConfidenceCdcCount = coverage.HighConfidenceCdcCount, - MediumConfidenceCdcCount = coverage.MediumConfidenceCdcCount, - LowConfidenceCdcCount = coverage.LowConfidenceCdcCount, - UnknownCdcCount = coverage.UnknownCdcCount, - DataSetCount = mergedDataSets.Count, - FileCount = coverage.FileCount, - VariableTypeReadAttemptCount = coverage.VariableTypeReadAttemptCount, - VariableTypeReadSuccessCount = coverage.VariableTypeReadSuccessCount, - VariableTypeReadFailureCount = coverage.VariableTypeReadFailureCount, - ExactMmsTypeCount = coverage.ExactMmsTypeCount, - ReportControlCount = coverage.ReportControlCount, - BufferedReportControlCount = coverage.BufferedReportControlCount, - UnbufferedReportControlCount = coverage.UnbufferedReportControlCount, - GooseControlBlockCount = coverage.GooseControlBlockCount, - SampledValueControlBlockCount = coverage.SampledValueControlBlockCount, - SettingGroupControlCount = coverage.SettingGroupControlCount, - LogControlCount = coverage.LogControlCount - }; - - 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 = mergedCoverage, - LogicalDevices = source.LogicalDevices, - FileDirectory = source.FileDirectory, - DataSets = mergedDataSets.OrderBy(item => item.Reference, StringComparer.OrdinalIgnoreCase).ToArray(), - 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 LiveIedDataSetModel MergeDataSet( - LiveIedDataSetModel source, - MmsRcbAvailabilitySnapshot evidence) - { - var members = ConvertDataSetMembers(evidence); - return new LiveIedDataSetModel - { - Reference = source.Reference, - Domain = source.Domain, - LogicalNode = source.LogicalNode, - Name = source.Name, - IsDeletable = evidence.DataSetIsDeletable ?? source.IsDeletable, - MemberCount = members.Count, - Members = members, - UsedByReportControls = source.UsedByReportControls, - UsedByGooseControls = source.UsedByGooseControls, - UsedBySampledValueControls = source.UsedBySampledValueControls - }; - } - - private static LiveIedDataSetModel CreateDataSetFromEvidence( - LiveIedModelDiscoveryDocument source, - MmsRcbAvailabilitySnapshot evidence) - { - var (domain, logicalNode, name) = ParseDataSetReference(evidence.DataSetReference); - var usedByReports = source.ReportControls - .Where(control => NormalizeReference(control.DataSetReference) - .Equals(NormalizeReference(evidence.DataSetReference), StringComparison.OrdinalIgnoreCase)) - .Select(control => control.Reference) - .Distinct(StringComparer.OrdinalIgnoreCase) - .ToArray(); - var members = ConvertDataSetMembers(evidence); - return new LiveIedDataSetModel - { - Reference = evidence.DataSetReference, - Domain = domain, - LogicalNode = logicalNode, - Name = name, - IsDeletable = evidence.DataSetIsDeletable, - MemberCount = members.Count, - Members = members, - UsedByReportControls = usedByReports - }; - } - - private static IReadOnlyList ConvertDataSetMembers( - MmsRcbAvailabilitySnapshot evidence) - => 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 => $"{NormalizeReference(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(); - - private static (string Domain, string LogicalNode, string Name) ParseDataSetReference(string reference) - { - var normalized = NormalizeReference(reference); - var slash = normalized.IndexOf('/'); - var dot = normalized.LastIndexOf('.'); - if (slash <= 0 || dot <= slash || dot >= normalized.Length - 1) - throw new InvalidOperationException($"DataSet reference '{reference}' cannot be mapped into the live SCL model."); - return (normalized[..slash], normalized[(slash + 1)..dot], normalized[(dot + 1)..]); - } - - private static string FirstNonEmpty(params string?[] values) - => values.FirstOrDefault(value => !string.IsNullOrWhiteSpace(value))?.Trim() ?? string.Empty; - - '''.replace(' ', ' ') - if 'MergeLiveDataSetEvidence(' not in text: - if anchor not in text: - raise SystemExit('FilterLiveModel anchor not found') - text = text.replace(anchor, implementation + anchor, 1) - filter_path.write_text(text, encoding='utf-8') - - availability_tests = Path('tests/AR.Iec61850.Tests/Mms/MmsRcbAvailabilityTests.cs') - text = availability_tests.read_text(encoding='utf-8') - old = ''' Assert.True(result.IsSelectable); - Assert.Equal(2, result.DataSetMemberCount); - '''.replace(' ', ' ') - new = ''' Assert.True(result.IsSelectable); - Assert.Equal(2, result.DataSetMemberCount); - Assert.Equal(2, result.DataSetMembers.Count); - Assert.Equal("IED1LD0/XCBR1.Pos.stVal", result.DataSetMembers[0].UserReference); - '''.replace(' ', ' ') - if 'Assert.Equal(2, result.DataSetMembers.Count);' not in text: - if old not in text: - raise SystemExit('availability test anchor not found') - text = text.replace(old, new, 1) - availability_tests.write_text(text, encoding='utf-8') - - filter_tests = Path('tests/AR.Iec61850.Tests/Scl/SclReportControlFilterTests.cs') - text = filter_tests.read_text(encoding='utf-8') - if 'using AR.Iec61850.Mms;' not in text: - text = text.replace('using AR.Iec61850.Discovery;\n', 'using AR.Iec61850.Discovery;\nusing AR.Iec61850.Mms;\n', 1) - test = ''' - [Fact] - public void FilterLiveModel_Merges_ReadOnly_DataSet_Directory_Evidence_Before_Export() - { - var dataSet = new LiveIedDataSetModel - { - Reference = "IED1LD0/LLN0.DataSet", - Domain = "IED1LD0", - LogicalNode = "LLN0", - Name = "DataSet", - MemberCount = 1, - Members = Array.Empty() - }; - var selected = 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[] { selected }, - Coverage = new LiveIedModelDiscoveryCoverage - { - ReportControlCount = 1, - UnbufferedReportControlCount = 1, - DataSetCount = 1 - } - }; - var availability = new MmsRcbAvailabilityResult - { - ReportControls = new[] - { - new MmsRcbAvailabilitySnapshot - { - Reference = selected.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 filtered = SclReportControlFilter.FilterLiveModel(source, selected.Reference, availability); - - 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); - } - - '''.replace(' ', ' ') - if 'FilterLiveModel_Merges_ReadOnly_DataSet_Directory_Evidence_Before_Export' not in text: - marker = ' private static string Fixture()\n' - if marker not in text: - raise SystemExit('filter test anchor not found') - text = text.replace(marker, test + marker, 1) - filter_tests.write_text(text, encoding='utf-8') - - - uses: actions/setup-dotnet@v4 - with: - dotnet-version: 8.0.x - - - name: Restore and test - run: | - dotnet restore ARIEC61850.sln - dotnet test ARIEC61850.sln -c Release --no-restore - - - name: Commit tested fix - shell: bash - run: | - if git diff --quiet -- src tests; then - exit 0 - fi - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add src tests - git commit -m "Preserve live DataSet members for selected-RCB export" - git push origin HEAD:fix/live-rcb-dataset-export From fdba0402e6135dd1c2dcb2967bc751a666b1f3d0 Mon Sep 17 00:00:00 2001 From: masarray Date: Mon, 20 Jul 2026 06:01:09 +0700 Subject: [PATCH 12/14] Remove temporary tested patch workflow --- .../apply-live-rcb-patch-v2-temp.yml | 49 ------------------- 1 file changed, 49 deletions(-) delete mode 100644 .github/workflows/apply-live-rcb-patch-v2-temp.yml diff --git a/.github/workflows/apply-live-rcb-patch-v2-temp.yml b/.github/workflows/apply-live-rcb-patch-v2-temp.yml deleted file mode 100644 index f6532e8f..00000000 --- a/.github/workflows/apply-live-rcb-patch-v2-temp.yml +++ /dev/null @@ -1,49 +0,0 @@ -name: Temporary apply tested live RCB patch v2 - -on: - push: - branches: [ fix/live-rcb-dataset-export ] - -permissions: - contents: write - -jobs: - apply-test-commit: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - with: - ref: fix/live-rcb-dataset-export - fetch-depth: 0 - - - name: Apply patch - shell: bash - run: | - if grep -q "DataSetMembers { get; init; }" src/AR.Iec61850/Mms/MmsRcbAvailability.cs; then - echo "Patch already applied." - else - git apply --check scripts/live-rcb-dataset-export.patch - git apply scripts/live-rcb-dataset-export.patch - fi - - - uses: actions/setup-dotnet@v4 - with: - dotnet-version: 8.0.x - - - name: Restore and run all tests - shell: bash - run: | - dotnet restore ARIEC61850.sln - dotnet test ARIEC61850.sln -c Release --no-restore - - - name: Commit tested source changes - shell: bash - run: | - if git diff --quiet -- src tests; then - exit 0 - fi - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add src tests - git commit -m "Preserve live DataSet members for selected-RCB export" - git push origin HEAD:fix/live-rcb-dataset-export From d32c3c388cfb64e7c84e573a13a5bf248e4b1071 Mon Sep 17 00:00:00 2001 From: masarray Date: Mon, 20 Jul 2026 06:01:30 +0700 Subject: [PATCH 13/14] Remove temporary source patch workflow --- .github/workflows/source-snapshot-temp.yml | 51 ---------------------- 1 file changed, 51 deletions(-) delete mode 100644 .github/workflows/source-snapshot-temp.yml diff --git a/.github/workflows/source-snapshot-temp.yml b/.github/workflows/source-snapshot-temp.yml deleted file mode 100644 index b4ba8632..00000000 --- a/.github/workflows/source-snapshot-temp.yml +++ /dev/null @@ -1,51 +0,0 @@ -name: Temporary apply remaining live RCB export fix - -on: - push: - branches: [ fix/live-rcb-dataset-export ] - -permissions: - contents: write - -jobs: - patch-test-commit: - if: github.actor != 'github-actions[bot]' - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - with: - ref: fix/live-rcb-dataset-export - fetch-depth: 0 - - - name: Apply filter and regression-test patch - shell: bash - run: | - if ! grep -q "MergeLiveDataSetEvidence" src/AR.Iec61850/Scl/Export/SclReportControlFilter.cs; then - git apply --check --exclude=src/AR.Iec61850/Mms/MmsRcbAvailability.cs scripts/live-rcb-dataset-export.patch - git apply --exclude=src/AR.Iec61850/Mms/MmsRcbAvailability.cs scripts/live-rcb-dataset-export.patch - fi - if ! grep -q "using AR.Iec61850.Mms;" src/AR.Iec61850/Scl/Export/SclReportControlFilter.cs; then - sed -i '/using AR.Iec61850.Discovery;/a using AR.Iec61850.Mms;' src/AR.Iec61850/Scl/Export/SclReportControlFilter.cs - fi - - - uses: actions/setup-dotnet@v4 - with: - dotnet-version: 8.0.x - - - name: Restore and test all engine projects - shell: bash - run: | - dotnet restore ARIEC61850.sln - dotnet test ARIEC61850.sln -c Release --no-restore - - - name: Commit tested source changes - shell: bash - run: | - if git diff --quiet -- src tests; then - exit 0 - fi - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add src tests - git commit -m "Preserve live DataSet members for selected-RCB export" - git push origin HEAD:fix/live-rcb-dataset-export From ef19d21699f21d334b015836feb18b673b7d2606 Mon Sep 17 00:00:00 2001 From: masarray Date: Mon, 20 Jul 2026 06:01:42 +0700 Subject: [PATCH 14/14] Remove temporary live RCB patch file --- scripts/live-rcb-dataset-export.patch | 331 -------------------------- 1 file changed, 331 deletions(-) delete mode 100644 scripts/live-rcb-dataset-export.patch diff --git a/scripts/live-rcb-dataset-export.patch b/scripts/live-rcb-dataset-export.patch deleted file mode 100644 index 5417fdfc..00000000 --- a/scripts/live-rcb-dataset-export.patch +++ /dev/null @@ -1,331 +0,0 @@ -diff --git a/src/AR.Iec61850/Mms/MmsRcbAvailability.cs b/src/AR.Iec61850/Mms/MmsRcbAvailability.cs -index ec3073b..6fcddea 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/SclReportControlFilter.cs b/src/AR.Iec61850/Scl/Export/SclReportControlFilter.cs -index 6fa3bd6..dfdd433 100644 ---- a/src/AR.Iec61850/Scl/Export/SclReportControlFilter.cs -+++ b/src/AR.Iec61850/Scl/Export/SclReportControlFilter.cs -@@ -237,6 +237,202 @@ public static SclReportControlFilterResult Filter( - }; - } - -+ public static LiveIedModelDiscoveryDocument FilterLiveModel( -+ LiveIedModelDiscoveryDocument source, -+ string selectedReportControlReference, -+ MmsRcbAvailabilityResult availability) -+ { -+ ArgumentNullException.ThrowIfNull(availability); -+ return FilterLiveModel( -+ MergeLiveDataSetEvidence(source, availability.ReportControls), -+ selectedReportControlReference); -+ } -+ -+ /// -+ /// Merges the exact DataSet directory returned by the read-only RCB availability -+ /// audit into a cloned live discovery document. Some relays expose the DataSet -+ /// member count during discovery but only return the actual member references when -+ /// GetDataSetDirectory is requested explicitly. The source model is never mutated. -+ /// -+ public static LiveIedModelDiscoveryDocument MergeLiveDataSetEvidence( -+ LiveIedModelDiscoveryDocument source, -+ IEnumerable availabilitySnapshots) -+ { -+ ArgumentNullException.ThrowIfNull(source); -+ ArgumentNullException.ThrowIfNull(availabilitySnapshots); -+ -+ var evidenceByReference = availabilitySnapshots -+ .Where(snapshot => snapshot.DataSetDirectorySuccess && -+ !string.IsNullOrWhiteSpace(snapshot.DataSetReference) && -+ snapshot.DataSetMembers.Count > 0) -+ .GroupBy(snapshot => NormalizeReference(snapshot.DataSetReference), StringComparer.OrdinalIgnoreCase) -+ .ToDictionary(group => group.Key, group => group.First(), StringComparer.OrdinalIgnoreCase); -+ -+ if (evidenceByReference.Count == 0) -+ return source; -+ -+ var mergedDataSets = new List(); -+ var mergedReferences = new HashSet(StringComparer.OrdinalIgnoreCase); -+ foreach (var dataSet in source.DataSets) -+ { -+ var normalized = NormalizeReference(dataSet.Reference); -+ if (!evidenceByReference.TryGetValue(normalized, out var evidence)) -+ { -+ mergedDataSets.Add(dataSet); -+ mergedReferences.Add(normalized); -+ continue; -+ } -+ -+ mergedDataSets.Add(MergeDataSet(dataSet, evidence)); -+ mergedReferences.Add(normalized); -+ } -+ -+ foreach (var pair in evidenceByReference) -+ { -+ if (mergedReferences.Contains(pair.Key)) -+ continue; -+ mergedDataSets.Add(CreateDataSetFromEvidence(source, pair.Value)); -+ } -+ -+ var coverage = source.Coverage; -+ var mergedCoverage = new LiveIedModelDiscoveryCoverage -+ { -+ LogicalDeviceCount = coverage.LogicalDeviceCount, -+ LogicalNodeCount = coverage.LogicalNodeCount, -+ DataObjectCount = coverage.DataObjectCount, -+ DataAttributeCount = coverage.DataAttributeCount, -+ ExactFunctionalConstraintCount = coverage.ExactFunctionalConstraintCount, -+ HighConfidenceCdcCount = coverage.HighConfidenceCdcCount, -+ MediumConfidenceCdcCount = coverage.MediumConfidenceCdcCount, -+ LowConfidenceCdcCount = coverage.LowConfidenceCdcCount, -+ UnknownCdcCount = coverage.UnknownCdcCount, -+ DataSetCount = mergedDataSets.Count, -+ FileCount = coverage.FileCount, -+ VariableTypeReadAttemptCount = coverage.VariableTypeReadAttemptCount, -+ VariableTypeReadSuccessCount = coverage.VariableTypeReadSuccessCount, -+ VariableTypeReadFailureCount = coverage.VariableTypeReadFailureCount, -+ ExactMmsTypeCount = coverage.ExactMmsTypeCount, -+ ReportControlCount = coverage.ReportControlCount, -+ BufferedReportControlCount = coverage.BufferedReportControlCount, -+ UnbufferedReportControlCount = coverage.UnbufferedReportControlCount, -+ GooseControlBlockCount = coverage.GooseControlBlockCount, -+ SampledValueControlBlockCount = coverage.SampledValueControlBlockCount, -+ SettingGroupControlCount = coverage.SettingGroupControlCount, -+ LogControlCount = coverage.LogControlCount -+ }; -+ -+ 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 = mergedCoverage, -+ LogicalDevices = source.LogicalDevices, -+ FileDirectory = source.FileDirectory, -+ DataSets = mergedDataSets -+ .OrderBy(item => item.Reference, StringComparer.OrdinalIgnoreCase) -+ .ToArray(), -+ 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 LiveIedDataSetModel MergeDataSet( -+ LiveIedDataSetModel source, -+ MmsRcbAvailabilitySnapshot evidence) -+ { -+ var members = ConvertDataSetMembers(evidence); -+ return new LiveIedDataSetModel -+ { -+ Reference = source.Reference, -+ Domain = source.Domain, -+ LogicalNode = source.LogicalNode, -+ Name = source.Name, -+ IsDeletable = evidence.DataSetIsDeletable ?? source.IsDeletable, -+ MemberCount = members.Count, -+ Members = members, -+ UsedByReportControls = source.UsedByReportControls, -+ UsedByGooseControls = source.UsedByGooseControls, -+ UsedBySampledValueControls = source.UsedBySampledValueControls -+ }; -+ } -+ -+ private static LiveIedDataSetModel CreateDataSetFromEvidence( -+ LiveIedModelDiscoveryDocument source, -+ MmsRcbAvailabilitySnapshot evidence) -+ { -+ var (domain, logicalNode, name) = ParseDataSetReference(evidence.DataSetReference); -+ var usedByReports = source.ReportControls -+ .Where(control => NormalizeReference(control.DataSetReference) -+ .Equals(NormalizeReference(evidence.DataSetReference), StringComparison.OrdinalIgnoreCase)) -+ .Select(control => control.Reference) -+ .Distinct(StringComparer.OrdinalIgnoreCase) -+ .ToArray(); -+ var members = ConvertDataSetMembers(evidence); -+ return new LiveIedDataSetModel -+ { -+ Reference = evidence.DataSetReference, -+ Domain = domain, -+ LogicalNode = logicalNode, -+ Name = name, -+ IsDeletable = evidence.DataSetIsDeletable, -+ MemberCount = members.Count, -+ Members = members, -+ UsedByReportControls = usedByReports -+ }; -+ } -+ -+ private static IReadOnlyList ConvertDataSetMembers( -+ MmsRcbAvailabilitySnapshot evidence) -+ => 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 => $"{NormalizeReference(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(); -+ -+ private static (string Domain, string LogicalNode, string Name) ParseDataSetReference(string reference) -+ { -+ var normalized = NormalizeReference(reference); -+ var slash = normalized.IndexOf('/'); -+ var dot = normalized.LastIndexOf('.'); -+ if (slash <= 0 || dot <= slash || dot >= normalized.Length - 1) -+ throw new InvalidOperationException($"DataSet reference '{reference}' cannot be mapped into the live SCL model."); -+ return (normalized[..slash], normalized[(slash + 1)..dot], normalized[(dot + 1)..]); -+ } -+ -+ private static string FirstNonEmpty(params string?[] values) -+ => values.FirstOrDefault(value => !string.IsNullOrWhiteSpace(value))?.Trim() ?? string.Empty; -+ - public static LiveIedModelDiscoveryDocument FilterLiveModel( - LiveIedModelDiscoveryDocument source, - string selectedReportControlReference) -diff --git a/tests/AR.Iec61850.Tests/Mms/MmsRcbAvailabilityTests.cs b/tests/AR.Iec61850.Tests/Mms/MmsRcbAvailabilityTests.cs -index 0605682..d3559f9 100644 ---- a/tests/AR.Iec61850.Tests/Mms/MmsRcbAvailabilityTests.cs -+++ b/tests/AR.Iec61850.Tests/Mms/MmsRcbAvailabilityTests.cs -@@ -17,6 +17,8 @@ public void Evaluate_Requires_Explicit_Free_Runtime_State_For_Green_Available() - Assert.Equal(MmsRcbAvailabilityConfidence.Exact, result.Confidence); - Assert.True(result.IsSelectable); - Assert.Equal(2, result.DataSetMemberCount); -+ Assert.Equal(2, result.DataSetMembers.Count); -+ Assert.Equal("IED1LD0/XCBR1.Pos.stVal", result.DataSetMembers[0].UserReference); - } - - [Theory] -diff --git a/tests/AR.Iec61850.Tests/Scl/SclReportControlFilterTests.cs b/tests/AR.Iec61850.Tests/Scl/SclReportControlFilterTests.cs -index 49001e2..549231b 100644 ---- a/tests/AR.Iec61850.Tests/Scl/SclReportControlFilterTests.cs -+++ b/tests/AR.Iec61850.Tests/Scl/SclReportControlFilterTests.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; - -@@ -181,6 +182,75 @@ public void FilterLiveModel_Retains_One_Rcb_And_Validates_DataSet() - Assert.Equal(2, source.ReportControls.Count); - } - -+ -+ [Fact] -+ public void FilterLiveModel_Merges_ReadOnly_DataSet_Directory_Evidence_Before_Export() -+ { -+ var dataSet = new LiveIedDataSetModel -+ { -+ Reference = "IED1LD0/LLN0.DataSet", -+ Domain = "IED1LD0", -+ LogicalNode = "LLN0", -+ Name = "DataSet", -+ MemberCount = 1, -+ Members = Array.Empty() -+ }; -+ var selected = 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[] { selected }, -+ Coverage = new LiveIedModelDiscoveryCoverage -+ { -+ ReportControlCount = 1, -+ UnbufferedReportControlCount = 1, -+ DataSetCount = 1 -+ } -+ }; -+ var availability = new MmsRcbAvailabilityResult -+ { -+ ReportControls = new[] -+ { -+ new MmsRcbAvailabilitySnapshot -+ { -+ Reference = selected.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 filtered = SclReportControlFilter.FilterLiveModel(source, selected.Reference, availability); -+ -+ 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); -+ } -+ - private static string Fixture() - => """ -