From 9c73f25722f7e9bde10b1d543af4433edbc54ebf Mon Sep 17 00:00:00 2001 From: masarray Date: Sun, 19 Jul 2026 20:24:01 +0700 Subject: [PATCH 1/9] ci: add temporary source snapshot artifact --- .github/workflows/source-snapshot-temp.yml | 21 +++++++++++++++++++++ 1 file changed, 21 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..4b4ab465 --- /dev/null +++ b/.github/workflows/source-snapshot-temp.yml @@ -0,0 +1,21 @@ +name: Temporary source snapshot + +on: + pull_request: + branches: [ main ] + workflow_dispatch: + +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 655c77f1ff50ef8bf050266354a1d0f49be82a3b Mon Sep 17 00:00:00 2001 From: masarray Date: Sun, 19 Jul 2026 20:46:45 +0700 Subject: [PATCH 2/9] Add RCB owner evidence to report inventory --- src/AR.Iec61850/Mms/MmsReportInventory.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/AR.Iec61850/Mms/MmsReportInventory.cs b/src/AR.Iec61850/Mms/MmsReportInventory.cs index cc3fe1af..505c701c 100644 --- a/src/AR.Iec61850/Mms/MmsReportInventory.cs +++ b/src/AR.Iec61850/Mms/MmsReportInventory.cs @@ -24,6 +24,7 @@ public sealed class MmsReportControlCandidate public string EnabledState { get; set; } = string.Empty; public string ReservationState { get; set; } = string.Empty; public string ReservationTimeSeconds { get; set; } = string.Empty; + public string Owner { get; set; } = string.Empty; public string BufferTimeMs { get; set; } = string.Empty; public string TriggerOptions { get; set; } = string.Empty; public string OptionalFields { get; set; } = string.Empty; From 32f7046b55b8ba5cdb0231c61e1a8d7c19e347ca Mon Sep 17 00:00:00 2001 From: masarray Date: Sun, 19 Jul 2026 20:47:56 +0700 Subject: [PATCH 3/9] Add conservative RCB availability classification --- src/AR.Iec61850/Mms/MmsRcbAvailability.cs | 242 ++++++++++++++++++++++ 1 file changed, 242 insertions(+) create mode 100644 src/AR.Iec61850/Mms/MmsRcbAvailability.cs diff --git a/src/AR.Iec61850/Mms/MmsRcbAvailability.cs b/src/AR.Iec61850/Mms/MmsRcbAvailability.cs new file mode 100644 index 00000000..ec3073b2 --- /dev/null +++ b/src/AR.Iec61850/Mms/MmsRcbAvailability.cs @@ -0,0 +1,242 @@ +namespace AR.Iec61850.Mms; + +public enum MmsRcbOperationalAvailability +{ + Available, + InUse, + UsedByCaller, + Unknown, + NoDataSet, + DataSetEmpty, + DataSetUnreadable +} + +public enum MmsRcbAvailabilityConfidence +{ + Exact, + Reduced, + Unknown +} + +public sealed class MmsRcbAvailabilityOptions +{ + public int MaxReportControls { get; init; } = 512; + public bool ReadDataSetDirectories { get; init; } = true; + public IReadOnlySet CallerOwnedRcbReferences { get; init; } + = new HashSet(StringComparer.OrdinalIgnoreCase); +} + +public sealed class MmsRcbAvailabilitySnapshot +{ + public DateTimeOffset CheckedAtUtc { get; init; } = DateTimeOffset.UtcNow; + public string Reference { get; init; } = string.Empty; + public string Domain { get; init; } = string.Empty; + public string LogicalNode { get; init; } = string.Empty; + public string Name { get; init; } = string.Empty; + public string Mode { get; init; } = string.Empty; + public bool Buffered { get; init; } + public string DataSetReference { get; init; } = string.Empty; + public string ReportId { get; init; } = string.Empty; + public string ConfRev { get; init; } = string.Empty; + public string EnabledState { get; init; } = string.Empty; + public string ReservationState { get; init; } = string.Empty; + public string ReservationTimeSeconds { get; init; } = string.Empty; + public string Owner { get; init; } = string.Empty; + public bool DataSetDirectoryRead { get; init; } + public bool DataSetDirectorySuccess { get; init; } + public int DataSetMemberCount { get; init; } + public MmsRcbOperationalAvailability Availability { get; init; } + public MmsRcbAvailabilityConfidence Confidence { get; init; } = MmsRcbAvailabilityConfidence.Unknown; + public string Reason { get; init; } = string.Empty; + public IReadOnlyList ProbeDiagnostics { get; init; } = Array.Empty(); + + public bool IsSelectable => Availability is MmsRcbOperationalAvailability.Available or MmsRcbOperationalAvailability.UsedByCaller; + public string Type => Buffered ? "Buffered" : "Unbuffered"; + public string Summary => $"{Reference}: {Availability} ({Confidence}) - {Reason}"; +} + +public sealed class MmsRcbAvailabilityResult +{ + public DateTimeOffset CheckedAtUtc { get; init; } = DateTimeOffset.UtcNow; + public IReadOnlyList ReportControls { get; init; } = Array.Empty(); + public IReadOnlyList Warnings { get; init; } = Array.Empty(); + + 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 static class MmsRcbAvailabilityEvaluator +{ + public static MmsRcbAvailabilitySnapshot Evaluate( + MmsReportControlCandidate candidate, + MmsDataSetDirectoryResult? dataSetDirectory, + bool callerOwned, + DateTimeOffset? checkedAtUtc = null) + { + ArgumentNullException.ThrowIfNull(candidate); + + var enabled = ParseBool(candidate.EnabledState); + var reserved = ParseBool(candidate.ReservationState); + var reservationSeconds = ParseUnsigned(candidate.ReservationTimeSeconds); + var hasOwner = HasOwner(candidate.Owner); + var hasDataSet = !string.IsNullOrWhiteSpace(candidate.DataSetReference); + var directoryRead = dataSetDirectory is not null; + var directorySuccess = dataSetDirectory?.IsSuccess == true; + var memberCount = dataSetDirectory?.Members.Count ?? 0; + + var availability = MmsRcbOperationalAvailability.Unknown; + var confidence = MmsRcbAvailabilityConfidence.Unknown; + var reason = "Runtime ownership state could not be proven from the exposed RCB attributes."; + + if (callerOwned) + { + availability = MmsRcbOperationalAvailability.UsedByCaller; + confidence = MmsRcbAvailabilityConfidence.Exact; + reason = "This RCB is active in the caller's current association/session."; + } + else if (enabled == true || reserved == true || reservationSeconds > 0 || hasOwner) + { + availability = MmsRcbOperationalAvailability.InUse; + confidence = MmsRcbAvailabilityConfidence.Exact; + reason = BuildBusyReason(candidate, enabled, reserved, reservationSeconds, hasOwner); + } + else if (!hasDataSet) + { + availability = MmsRcbOperationalAvailability.NoDataSet; + confidence = enabled == false ? MmsRcbAvailabilityConfidence.Exact : MmsRcbAvailabilityConfidence.Reduced; + reason = "The RCB does not reference a static DataSet and cannot be exported as a populated legacy-SAS report block."; + } + else if (directoryRead && !directorySuccess) + { + availability = MmsRcbOperationalAvailability.DataSetUnreadable; + confidence = MmsRcbAvailabilityConfidence.Exact; + reason = string.IsNullOrWhiteSpace(dataSetDirectory?.Message) + ? "The referenced DataSet directory could not be read." + : $"The referenced DataSet directory could not be read: {dataSetDirectory.Message}"; + } + else if (directorySuccess && memberCount == 0) + { + availability = MmsRcbOperationalAvailability.DataSetEmpty; + confidence = MmsRcbAvailabilityConfidence.Exact; + reason = "The referenced DataSet is empty."; + } + else if (enabled == false && ReservationIsExplicitlyFree(candidate, reserved, reservationSeconds, hasOwner)) + { + availability = MmsRcbOperationalAvailability.Available; + confidence = MmsRcbAvailabilityConfidence.Exact; + reason = "RptEna is false, reservation state is explicitly free, and the referenced DataSet is populated."; + } + else if (enabled == false && candidate.Buffered && directorySuccess && memberCount > 0) + { + // Some Edition 1 BRCBs expose RptEna but do not expose Owner/ResvTms. + // Do not convert the missing reservation evidence into a green Available state. + availability = MmsRcbOperationalAvailability.Unknown; + confidence = MmsRcbAvailabilityConfidence.Reduced; + reason = "RptEna is false and the DataSet is populated, but this BRCB does not expose enough reservation evidence to prove availability."; + } + else if (enabled == false && !candidate.Buffered && reserved == null) + { + availability = MmsRcbOperationalAvailability.Unknown; + confidence = MmsRcbAvailabilityConfidence.Reduced; + reason = "RptEna is false, but the URCB Resv state was not returned."; + } + + return new MmsRcbAvailabilitySnapshot + { + CheckedAtUtc = checkedAtUtc ?? DateTimeOffset.UtcNow, + Reference = candidate.Reference, + Domain = candidate.Domain, + LogicalNode = candidate.LogicalNode, + Name = candidate.Name, + Mode = candidate.Mode, + Buffered = candidate.Buffered, + DataSetReference = candidate.DataSetReference, + ReportId = candidate.ReportId, + ConfRev = candidate.ConfRev, + EnabledState = candidate.EnabledState, + ReservationState = candidate.ReservationState, + ReservationTimeSeconds = candidate.ReservationTimeSeconds, + Owner = candidate.Owner, + DataSetDirectoryRead = directoryRead, + DataSetDirectorySuccess = directorySuccess, + DataSetMemberCount = memberCount, + Availability = availability, + Confidence = confidence, + Reason = reason, + ProbeDiagnostics = candidate.ProbeDiagnostics.ToArray() + }; + } + + private static bool ReservationIsExplicitlyFree( + MmsReportControlCandidate candidate, + bool? reserved, + ulong? reservationSeconds, + bool hasOwner) + { + if (hasOwner) + return false; + + return candidate.Buffered + ? reservationSeconds == 0 + : reserved == false; + } + + private static string BuildBusyReason( + MmsReportControlCandidate candidate, + bool? enabled, + bool? reserved, + ulong? reservationSeconds, + bool hasOwner) + { + var evidence = new List(); + if (enabled == true) + evidence.Add("RptEna=true"); + if (reserved == true) + evidence.Add("Resv=true"); + if (reservationSeconds > 0) + evidence.Add($"ResvTms={reservationSeconds}"); + if (hasOwner) + evidence.Add($"Owner={candidate.Owner}"); + return $"The RCB is in use or reserved ({string.Join(", ", evidence)})."; + } + + internal static bool? ParseBool(string? value) + { + var text = (value ?? string.Empty).Trim(); + if (text.Length == 0 || text == "-") + return null; + if (bool.TryParse(text, out var parsed)) + return parsed; + if (text is "1" or "01" || text.Equals("yes", StringComparison.OrdinalIgnoreCase) || text.Equals("on", StringComparison.OrdinalIgnoreCase)) + return true; + if (text is "0" or "00" || text.Equals("no", StringComparison.OrdinalIgnoreCase) || text.Equals("off", StringComparison.OrdinalIgnoreCase)) + return false; + return null; + } + + internal static ulong? ParseUnsigned(string? value) + { + var text = (value ?? string.Empty).Trim(); + if (text.Length == 0 || text == "-") + return null; + return ulong.TryParse(text, out var parsed) ? parsed : null; + } + + internal static bool HasOwner(string? value) + { + var text = (value ?? string.Empty).Trim(); + if (text.Length == 0 || text == "-" || text == "[]" || text.Equals("null", StringComparison.OrdinalIgnoreCase)) + return false; + + var compact = text.Replace("0x", string.Empty, StringComparison.OrdinalIgnoreCase) + .Replace(":", string.Empty, StringComparison.Ordinal) + .Replace("-", string.Empty, StringComparison.Ordinal) + .Replace(" ", string.Empty, StringComparison.Ordinal); + return compact.Length > 0 && compact.Any(character => character != '0'); + } + + internal static string NormalizeReference(string? reference) + => (reference ?? string.Empty).Trim().Replace('$', '.'); +} From 27b0117db290fc3f6a059672ede0dc64633e4bc0 Mon Sep 17 00:00:00 2001 From: masarray Date: Sun, 19 Jul 2026 20:48:30 +0700 Subject: [PATCH 4/9] Add bounded read-only RCB availability checks --- .../Mms/MmsClientSession.RcbAvailability.cs | 134 ++++++++++++++++++ 1 file changed, 134 insertions(+) create mode 100644 src/AR.Iec61850/Mms/MmsClientSession.RcbAvailability.cs diff --git a/src/AR.Iec61850/Mms/MmsClientSession.RcbAvailability.cs b/src/AR.Iec61850/Mms/MmsClientSession.RcbAvailability.cs new file mode 100644 index 00000000..ce1efbb4 --- /dev/null +++ b/src/AR.Iec61850/Mms/MmsClientSession.RcbAvailability.cs @@ -0,0 +1,134 @@ +namespace AR.Iec61850.Mms; + +public sealed partial class MmsClientSession +{ + public async Task CheckReportControlAvailabilityAsync( + MmsReportInventory inventory, + MmsIedModelDirectory? directory = null, + MmsRcbAvailabilityOptions? options = null, + CancellationToken cancellationToken = default) + { + EnsureMmsReady(); + ArgumentNullException.ThrowIfNull(inventory); + options ??= new MmsRcbAvailabilityOptions(); + + var checkedAt = DateTimeOffset.UtcNow; + var warnings = new List(); + var snapshots = new List(); + var dataSetDirectories = new Dictionary(StringComparer.OrdinalIgnoreCase); + 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) + { + cancellationToken.ThrowIfCancellationRequested(); + var candidate = CloneReportControl(source); + await ProbeReportControlAttributesAsync(candidate, cancellationToken).ConfigureAwait(false); + await ProbeOwnerReadOnlyAsync(candidate, cancellationToken).ConfigureAwait(false); + + MmsDataSetDirectoryResult? dataSetDirectory = null; + var dataSetReference = MmsRcbAvailabilityEvaluator.NormalizeReference(candidate.DataSetReference); + if (options.ReadDataSetDirectories && dataSetReference.Length > 0) + { + if (!dataSetDirectories.TryGetValue(dataSetReference, out dataSetDirectory)) + { + dataSetDirectory = await GetDataSetDirectoryAsync(candidate.DataSetReference, directory, cancellationToken).ConfigureAwait(false); + dataSetDirectories[dataSetReference] = dataSetDirectory; + } + } + + snapshots.Add(MmsRcbAvailabilityEvaluator.Evaluate( + candidate, + dataSetDirectory, + callerOwned.Contains(MmsRcbAvailabilityEvaluator.NormalizeReference(candidate.Reference)), + checkedAt)); + } + + return new MmsRcbAvailabilityResult + { + CheckedAtUtc = checkedAt, + ReportControls = snapshots + .OrderByDescending(item => item.DataSetMemberCount > 0) + .ThenBy(item => AvailabilityRank(item.Availability)) + .ThenByDescending(item => item.Buffered) + .ThenBy(item => item.Reference, StringComparer.OrdinalIgnoreCase) + .ToArray(), + Warnings = warnings + }; + } + + private async Task ProbeOwnerReadOnlyAsync( + MmsReportControlCandidate candidate, + CancellationToken cancellationToken) + { + try + { + var reference = MmsObjectReference.Parse($"{candidate.Reference}.Owner", candidate.FunctionalConstraint); + var read = await ReadSingleVariableAsync(reference, cancellationToken).ConfigureAwait(false); + if (read.IsSuccess) + { + candidate.Owner = NormalizeReportAttributeText(read.Value); + candidate.ProbeDiagnostics.Add($"Owner item={reference.Item}: OK {MmsDataValueRenderer.ToCompactString(read.Value)}"); + } + else + { + candidate.ProbeDiagnostics.Add($"Owner item={reference.Item}: {read.Message}"); + } + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + candidate.ProbeDiagnostics.Add($"Owner: unsupported or unreadable ({ex.GetType().Name}: {ex.Message})"); + } + } + + private static MmsReportControlCandidate CloneReportControl(MmsReportControlCandidate source) + => new() + { + Domain = source.Domain, + LogicalNode = source.LogicalNode, + FunctionalConstraint = source.FunctionalConstraint, + Name = source.Name, + Reference = source.Reference, + Buffered = source.Buffered, + DataSetReference = source.DataSetReference, + ReportId = source.ReportId, + ConfRev = source.ConfRev, + IntegrityPeriodMs = source.IntegrityPeriodMs, + EnabledState = source.EnabledState, + ReservationState = source.ReservationState, + ReservationTimeSeconds = source.ReservationTimeSeconds, + Owner = source.Owner, + BufferTimeMs = source.BufferTimeMs, + TriggerOptions = source.TriggerOptions, + OptionalFields = source.OptionalFields, + Status = source.Status, + Attributes = source.Attributes.ToList() + }; + + private static int AvailabilityRank(MmsRcbOperationalAvailability availability) + => availability switch + { + MmsRcbOperationalAvailability.Available => 0, + MmsRcbOperationalAvailability.UsedByCaller => 1, + MmsRcbOperationalAvailability.InUse => 2, + MmsRcbOperationalAvailability.Unknown => 3, + MmsRcbOperationalAvailability.DataSetUnreadable => 4, + MmsRcbOperationalAvailability.DataSetEmpty => 5, + MmsRcbOperationalAvailability.NoDataSet => 6, + _ => 9 + }; +} From 9c86e736724349abcdf0ea0e07c79f9a0f178be1 Mon Sep 17 00:00:00 2001 From: masarray Date: Sun, 19 Jul 2026 20:50:01 +0700 Subject: [PATCH 5/9] Add object-model selected-RCB SCL filter --- .../Scl/Export/SclReportControlFilter.cs | 449 ++++++++++++++++++ 1 file changed, 449 insertions(+) create mode 100644 src/AR.Iec61850/Scl/Export/SclReportControlFilter.cs diff --git a/src/AR.Iec61850/Scl/Export/SclReportControlFilter.cs b/src/AR.Iec61850/Scl/Export/SclReportControlFilter.cs new file mode 100644 index 00000000..6fa3bd66 --- /dev/null +++ b/src/AR.Iec61850/Scl/Export/SclReportControlFilter.cs @@ -0,0 +1,449 @@ +using System.Xml.Linq; +using AR.Iec61850.Discovery; + +namespace AR.Iec61850.Scl.Export; + +public sealed record SclReportControlSelection( + string SelectionKey, + string ExportName = ""); + +public sealed class SclReportControlDescriptor +{ + public string SelectionKey { get; init; } = string.Empty; + public string IedName { get; init; } = string.Empty; + public string AccessPointName { get; init; } = string.Empty; + public string LogicalDeviceInstance { get; init; } = string.Empty; + public string LogicalNodePath { get; init; } = string.Empty; + public string Name { get; init; } = string.Empty; + public string DisplayReference { get; init; } = string.Empty; + public bool Buffered { get; init; } + public bool Indexed { get; init; } = true; + public int InstanceCount { get; init; } = 1; + public string DataSetName { get; init; } = string.Empty; + public string DataSetReference { get; init; } = string.Empty; + public int DataSetMemberCount { get; init; } + public bool DataSetResolved { get; init; } + + public string Type => Buffered ? "Buffered" : "Unbuffered"; + public bool HasPopulatedDataSet => DataSetResolved && DataSetMemberCount > 0; +} + +public sealed class SclReportControlInventoryResult +{ + public string SelectedIedName { get; init; } = string.Empty; + public string SelectedAccessPointName { get; init; } = string.Empty; + public IReadOnlyList ReportControls { get; init; } = Array.Empty(); + public IReadOnlyList Findings { get; init; } = Array.Empty(); +} + +public sealed class SclReportControlFilterOptions +{ + public string IedName { get; init; } = string.Empty; + public string AccessPointName { get; init; } = string.Empty; + public IReadOnlyList SelectedReportControls { get; init; } + = Array.Empty(); + public bool RequireExactlyOneReportControl { get; init; } = true; + public bool RemoveUnreferencedDataSets { get; init; } + public bool CollapseIndexedSelectionToSingleInstance { get; init; } = true; +} + +public sealed class SclReportControlFilterResult +{ + public XDocument Document { get; init; } = new(); + public IReadOnlyList RetainedReportControls { get; init; } = Array.Empty(); + public int RemovedReportControlCount { get; init; } + public int RemovedDataSetCount { get; init; } + public IReadOnlyList Findings { get; init; } = Array.Empty(); +} + +public static class SclReportControlFilter +{ + private static readonly XNamespace Scl = "http://www.iec.ch/61850/2003/SCL"; + + public static SclReportControlInventoryResult InspectFile( + string inputPath, + string iedName = "", + string accessPointName = "") + { + ArgumentException.ThrowIfNullOrWhiteSpace(inputPath); + using var stream = File.OpenRead(inputPath); + return Inspect( + XDocument.Load(stream, LoadOptions.PreserveWhitespace | LoadOptions.SetLineInfo), + Path.GetFileName(inputPath), + iedName, + accessPointName); + } + + public static SclReportControlInventoryResult Inspect( + XDocument source, + string sourceName = "", + string iedName = "", + string accessPointName = "") + { + ArgumentNullException.ThrowIfNull(source); + var root = source.Root ?? throw new InvalidDataException("SCL document has no root element."); + if (!Is(root, "SCL")) + throw new InvalidDataException("The selected file is not an IEC 61850 SCL document."); + + var findings = new List(); + var selectedIed = SelectIed(root, sourceName, iedName); + var accessPoints = selectedIed.Elements().Where(element => Is(element, "AccessPoint")).ToArray(); + if (!string.IsNullOrWhiteSpace(accessPointName)) + { + accessPoints = accessPoints + .Where(element => Same(Attr(element, "name"), accessPointName)) + .ToArray(); + if (accessPoints.Length == 0) + throw new InvalidOperationException($"AccessPoint '{accessPointName}' was not found in IED '{Attr(selectedIed, "name")}'."); + } + + var descriptors = new List(); + foreach (var accessPoint in accessPoints) + { + foreach (var logicalDevice in accessPoint.Descendants().Where(element => Is(element, "LDevice"))) + { + foreach (var logicalNode in logicalDevice.Elements().Where(element => Is(element, "LN0") || Is(element, "LN"))) + { + foreach (var reportControl in logicalNode.Elements().Where(element => Is(element, "ReportControl"))) + descriptors.Add(BuildDescriptor(selectedIed, accessPoint, logicalDevice, logicalNode, reportControl)); + } + } + } + + foreach (var descriptor in descriptors.Where(item => !item.DataSetResolved)) + { + findings.Add(new InteroperableSclFinding + { + Severity = "Warning", + Code = "SCL.REPORT_DATASET_UNRESOLVED", + Reference = descriptor.DisplayReference, + Message = string.IsNullOrWhiteSpace(descriptor.DataSetName) + ? "The ReportControl has no datSet reference." + : $"The ReportControl references DataSet '{descriptor.DataSetName}', but it was not found in the same Logical Node." + }); + } + + foreach (var descriptor in descriptors.Where(item => item.DataSetResolved && item.DataSetMemberCount == 0)) + { + findings.Add(new InteroperableSclFinding + { + Severity = "Warning", + Code = "SCL.REPORT_DATASET_EMPTY", + Reference = descriptor.DisplayReference, + Message = $"The referenced DataSet '{descriptor.DataSetName}' contains no FCDA members." + }); + } + + return new SclReportControlInventoryResult + { + SelectedIedName = Attr(selectedIed, "name"), + SelectedAccessPointName = accessPoints.Length == 1 ? Attr(accessPoints[0], "name") : accessPointName, + ReportControls = descriptors + .OrderByDescending(item => item.HasPopulatedDataSet) + .ThenByDescending(item => item.Buffered) + .ThenBy(item => item.DisplayReference, StringComparer.OrdinalIgnoreCase) + .ToArray(), + Findings = findings + }; + } + + public static SclReportControlFilterResult Filter( + XDocument source, + SclReportControlFilterOptions options, + string sourceName = "") + { + ArgumentNullException.ThrowIfNull(source); + ArgumentNullException.ThrowIfNull(options); + if (options.SelectedReportControls.Count == 0) + throw new InvalidOperationException("At least one ReportControl selection is required."); + if (options.RequireExactlyOneReportControl && options.SelectedReportControls.Count != 1) + throw new InvalidOperationException("Legacy SAS export requires exactly one selected ReportControl."); + + var document = new XDocument(source); + var inventory = Inspect(document, sourceName, options.IedName, options.AccessPointName); + var selectedByKey = options.SelectedReportControls + .GroupBy(selection => NormalizeKey(selection.SelectionKey), StringComparer.OrdinalIgnoreCase) + .ToDictionary(group => group.Key, group => group.First(), StringComparer.OrdinalIgnoreCase); + var availableByKey = inventory.ReportControls.ToDictionary( + descriptor => NormalizeKey(descriptor.SelectionKey), + descriptor => descriptor, + StringComparer.OrdinalIgnoreCase); + + foreach (var requested in selectedByKey.Keys) + { + if (!availableByKey.ContainsKey(requested)) + throw new InvalidOperationException($"Selected ReportControl '{selectedByKey[requested].SelectionKey}' was not found in the target IED/AccessPoint."); + } + + var selectedDescriptors = selectedByKey.Keys.Select(key => availableByKey[key]).ToArray(); + foreach (var descriptor in selectedDescriptors) + { + if (!descriptor.DataSetResolved) + throw new InvalidOperationException($"Selected ReportControl '{descriptor.DisplayReference}' does not resolve a DataSet in the same Logical Node."); + if (descriptor.DataSetMemberCount <= 0) + throw new InvalidOperationException($"Selected ReportControl '{descriptor.DisplayReference}' references an empty DataSet."); + } + + var selectedIed = SelectIed(document.Root!, sourceName, inventory.SelectedIedName); + var removedReportControls = 0; + foreach (var element in selectedIed.Descendants().Where(item => Is(item, "ReportControl")).ToArray()) + { + var descriptor = BuildDescriptorForElement(selectedIed, element); + var key = NormalizeKey(descriptor.SelectionKey); + if (!selectedByKey.TryGetValue(key, out var selection)) + { + element.Remove(); + removedReportControls++; + continue; + } + + if (options.CollapseIndexedSelectionToSingleInstance) + CollapseToSingleInstance(element, selection.ExportName); + } + + var removedDataSets = options.RemoveUnreferencedDataSets + ? RemoveUnreferencedDataSets(selectedIed) + : 0; + + var after = Inspect(document, sourceName, inventory.SelectedIedName, options.AccessPointName); + if (options.RequireExactlyOneReportControl && after.ReportControls.Count != 1) + throw new InvalidDataException($"Filtered SCL must contain exactly one ReportControl, but contains {after.ReportControls.Count}."); + + var retainedKeys = after.ReportControls.Select(item => NormalizeKey(item.SelectionKey)).ToHashSet(StringComparer.OrdinalIgnoreCase); + foreach (var selected in selectedByKey.Keys) + { + if (!retainedKeys.Contains(selected) && string.IsNullOrWhiteSpace(selectedByKey[selected].ExportName)) + throw new InvalidDataException($"Filtered SCL lost selected ReportControl '{selectedByKey[selected].SelectionKey}'."); + } + + var findings = new List(after.Findings) + { + new() + { + Severity = "Info", + Code = "SCL.REPORT_FILTER_APPLIED", + Reference = string.Join(", ", after.ReportControls.Select(item => item.DisplayReference)), + Message = $"Retained {after.ReportControls.Count} ReportControl(s), removed {removedReportControls}, and removed {removedDataSets} unreferenced DataSet(s)." + } + }; + + return new SclReportControlFilterResult + { + Document = document, + RetainedReportControls = after.ReportControls, + RemovedReportControlCount = removedReportControls, + RemovedDataSetCount = removedDataSets, + Findings = findings + }; + } + + public static LiveIedModelDiscoveryDocument FilterLiveModel( + LiveIedModelDiscoveryDocument source, + string selectedReportControlReference) + { + ArgumentNullException.ThrowIfNull(source); + ArgumentException.ThrowIfNullOrWhiteSpace(selectedReportControlReference); + var normalized = NormalizeReference(selectedReportControlReference); + var matches = source.ReportControls + .Where(candidate => NormalizeReference(candidate.Reference).Equals(normalized, StringComparison.OrdinalIgnoreCase)) + .ToArray(); + if (matches.Length == 0) + { + var requestedName = LastName(selectedReportControlReference); + matches = source.ReportControls + .Where(candidate => candidate.Name.Equals(requestedName, StringComparison.OrdinalIgnoreCase)) + .ToArray(); + } + if (matches.Length != 1) + throw new InvalidOperationException(matches.Length == 0 + ? $"ReportControl '{selectedReportControlReference}' was not found in the model." + : $"ReportControl '{selectedReportControlReference}' is ambiguous in the model."); + + var selected = matches[0]; + var dataSet = source.DataSets.FirstOrDefault(item => NormalizeReference(item.Reference) + .Equals(NormalizeReference(selected.DataSetReference), StringComparison.OrdinalIgnoreCase)); + if (dataSet is null) + throw new InvalidOperationException($"Selected ReportControl '{selected.Reference}' references an unresolved DataSet '{selected.DataSetReference}'."); + if (dataSet.MemberCount <= 0 || dataSet.Members.Count == 0) + throw new InvalidOperationException($"Selected ReportControl '{selected.Reference}' references an empty DataSet."); + + var coverage = source.Coverage; + var filteredCoverage = 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 = coverage.DataSetCount, + FileCount = coverage.FileCount, + VariableTypeReadAttemptCount = coverage.VariableTypeReadAttemptCount, + VariableTypeReadSuccessCount = coverage.VariableTypeReadSuccessCount, + VariableTypeReadFailureCount = coverage.VariableTypeReadFailureCount, + ExactMmsTypeCount = coverage.ExactMmsTypeCount, + ReportControlCount = 1, + BufferedReportControlCount = selected.Buffered ? 1 : 0, + UnbufferedReportControlCount = selected.Buffered ? 0 : 1, + 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} Legacy-SAS filter retained {selected.Reference}.", + Coverage = filteredCoverage, + LogicalDevices = source.LogicalDevices, + FileDirectory = source.FileDirectory, + DataSets = source.DataSets, + ReportControls = new[] { selected }, + GooseControlBlocks = source.GooseControlBlocks, + SampledValueControlBlocks = source.SampledValueControlBlocks, + SettingGroupControls = source.SettingGroupControls, + LogControls = source.LogControls, + TypeTemplates = source.TypeTemplates, + VariableTypeDiscoveries = source.VariableTypeDiscoveries, + Warnings = source.Warnings + }; + } + + private static SclReportControlDescriptor BuildDescriptorForElement(XElement selectedIed, XElement reportControl) + { + var logicalNode = reportControl.Parent ?? throw new InvalidDataException("ReportControl has no Logical Node parent."); + var logicalDevice = logicalNode.Ancestors().First(element => Is(element, "LDevice")); + var accessPoint = logicalDevice.Ancestors().First(element => Is(element, "AccessPoint")); + return BuildDescriptor(selectedIed, accessPoint, logicalDevice, logicalNode, reportControl); + } + + private static SclReportControlDescriptor BuildDescriptor( + XElement ied, + XElement accessPoint, + XElement logicalDevice, + XElement logicalNode, + XElement reportControl) + { + var iedName = Attr(ied, "name"); + var apName = Attr(accessPoint, "name"); + var ldInst = Attr(logicalDevice, "inst"); + var lnPath = LogicalNodePath(logicalNode); + var name = Attr(reportControl, "name"); + var buffered = ParseBool(Attr(reportControl, "buffered")); + var indexedText = Attr(reportControl, "indexed"); + var indexed = indexedText.Length == 0 || ParseBool(indexedText); + var rptEnabled = reportControl.Elements().FirstOrDefault(element => Is(element, "RptEnabled")); + var instanceCount = ParsePositiveInt(Attr(rptEnabled, "max"), 1); + var dataSetName = Attr(reportControl, "datSet"); + var dataSet = logicalNode.Elements().FirstOrDefault(element => Is(element, "DataSet") && Same(Attr(element, "name"), dataSetName)); + var mode = buffered ? "BR" : "RP"; + return new SclReportControlDescriptor + { + SelectionKey = SelectionKey(iedName, apName, ldInst, lnPath, name), + IedName = iedName, + AccessPointName = apName, + LogicalDeviceInstance = ldInst, + LogicalNodePath = lnPath, + Name = name, + DisplayReference = $"{iedName}{ldInst}/{lnPath}.{mode}.{name}", + Buffered = buffered, + Indexed = indexed, + InstanceCount = instanceCount, + DataSetName = dataSetName, + DataSetReference = string.IsNullOrWhiteSpace(dataSetName) ? string.Empty : $"{iedName}{ldInst}/{lnPath}.{dataSetName}", + DataSetResolved = dataSet is not null, + DataSetMemberCount = dataSet?.Elements().Count(element => Is(element, "FCDA")) ?? 0 + }; + } + + private static void CollapseToSingleInstance(XElement reportControl, string exportName) + { + if (!string.IsNullOrWhiteSpace(exportName)) + reportControl.SetAttributeValue("name", exportName.Trim()); + reportControl.SetAttributeValue("indexed", "false"); + var rptEnabled = reportControl.Elements().FirstOrDefault(element => Is(element, "RptEnabled")); + if (rptEnabled is null) + { + rptEnabled = new XElement(Scl + "RptEnabled"); + reportControl.Add(rptEnabled); + } + rptEnabled.SetAttributeValue("max", "1"); + } + + private static int RemoveUnreferencedDataSets(XElement selectedIed) + { + var removed = 0; + foreach (var logicalNode in selectedIed.Descendants().Where(element => Is(element, "LN0") || Is(element, "LN"))) + { + var used = logicalNode.Elements() + .Where(element => Is(element, "ReportControl") || Is(element, "GSEControl") || Is(element, "SampledValueControl") || Is(element, "LogControl")) + .Select(element => Attr(element, "datSet")) + .Where(name => name.Length > 0) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + foreach (var dataSet in logicalNode.Elements().Where(element => Is(element, "DataSet")).ToArray()) + { + if (used.Contains(Attr(dataSet, "name"))) + continue; + dataSet.Remove(); + removed++; + } + } + return removed; + } + + private static XElement SelectIed(XElement root, string sourceName, string requestedIedName) + { + var ieds = root.Elements().Where(element => Is(element, "IED")).ToArray(); + if (ieds.Length == 0) + throw new InvalidDataException("The SCL document contains no IED element."); + if (!string.IsNullOrWhiteSpace(requestedIedName)) + return ieds.FirstOrDefault(element => Same(Attr(element, "name"), requestedIedName)) + ?? throw new InvalidOperationException($"IED '{requestedIedName}' was not found."); + if (ieds.Length == 1) + return ieds[0]; + var headerId = Attr(root.Elements().FirstOrDefault(element => Is(element, "Header")), "id"); + var headerMatch = ieds.FirstOrDefault(element => Same(Attr(element, "name"), headerId)); + if (headerMatch is not null) + return headerMatch; + var stem = Path.GetFileNameWithoutExtension(sourceName ?? string.Empty); + var fileMatches = ieds.Where(element => stem.Contains(Attr(element, "name"), StringComparison.OrdinalIgnoreCase)).ToArray(); + if (fileMatches.Length == 1) + return fileMatches[0]; + throw new InvalidOperationException($"The SCL contains multiple IEDs. Specify one of: {string.Join(", ", ieds.Select(element => Attr(element, "name")))}."); + } + + private static string LogicalNodePath(XElement logicalNode) + { + if (Is(logicalNode, "LN0")) + return "LLN0"; + return $"{Attr(logicalNode, "prefix")}{Attr(logicalNode, "lnClass")}{Attr(logicalNode, "inst")}"; + } + + private static string SelectionKey(string iedName, string accessPoint, string ldInst, string logicalNode, string name) + => $"{iedName}|{accessPoint}|{ldInst}|{logicalNode}|{name}"; + + private static string NormalizeKey(string? value) => (value ?? string.Empty).Trim(); + private static string NormalizeReference(string? value) => (value ?? string.Empty).Trim().Replace('$', '.'); + private static string LastName(string value) + { + var normalized = NormalizeReference(value); + var index = normalized.LastIndexOf('.'); + return index < 0 ? normalized : normalized[(index + 1)..]; + } + private static bool ParseBool(string value) => bool.TryParse(value, out var parsed) && parsed; + private static int ParsePositiveInt(string value, int fallback) => int.TryParse(value, out var parsed) && parsed > 0 ? parsed : fallback; + private static bool Same(string? left, string? right) => string.Equals(left?.Trim(), right?.Trim(), StringComparison.OrdinalIgnoreCase); + private static bool Is(XElement element, string localName) => string.Equals(element.Name.LocalName, localName, StringComparison.Ordinal); + private static string Attr(XElement? element, string name) => element?.Attributes().FirstOrDefault(attribute => attribute.Name.LocalName == name)?.Value?.Trim() ?? string.Empty; +} From 952deedd35d32045534aa38e2f3de007ebf82fb5 Mon Sep 17 00:00:00 2001 From: masarray Date: Sun, 19 Jul 2026 20:50:56 +0700 Subject: [PATCH 6/9] Add Edition-aware legacy SAS selected-RCB exporter --- .../Scl/Export/LegacySasSclExporter.cs | 226 ++++++++++++++++++ 1 file changed, 226 insertions(+) create mode 100644 src/AR.Iec61850/Scl/Export/LegacySasSclExporter.cs diff --git a/src/AR.Iec61850/Scl/Export/LegacySasSclExporter.cs b/src/AR.Iec61850/Scl/Export/LegacySasSclExporter.cs new file mode 100644 index 00000000..47c91afd --- /dev/null +++ b/src/AR.Iec61850/Scl/Export/LegacySasSclExporter.cs @@ -0,0 +1,226 @@ +using System.Text; +using System.Text.Json; +using System.Xml; +using System.Xml.Linq; + +namespace AR.Iec61850.Scl.Export; + +public sealed class LegacySasSclExportOptions +{ + public string IedName { get; init; } = string.Empty; + public string AccessPointName { get; init; } = string.Empty; + public SclSchemaProfile SchemaProfile { get; init; } = SclSchemaProfile.Edition1V16; + public SclReportControlSelection SelectedReportControl { get; init; } = new(string.Empty); + public bool RemoveUnreferencedDataSets { get; init; } + public string ToolId { get; init; } = "ARIEC61850"; +} + +public sealed record LegacySasSclExportResult +{ + public XDocument Document { get; init; } = new(); + public DateTimeOffset GeneratedAtUtc { get; init; } = DateTimeOffset.UtcNow; + public string InputPath { get; init; } = string.Empty; + public string OutputPath { get; init; } = string.Empty; + public string ReportPath { get; init; } = string.Empty; + public string SummaryPath { get; init; } = string.Empty; + public string IedName { get; init; } = string.Empty; + public string AccessPointName { get; init; } = string.Empty; + public string SclSchema { get; init; } = string.Empty; + public string RetainedReportControlReference { get; init; } = string.Empty; + public string RetainedDataSetName { get; init; } = string.Empty; + public int RetainedDataSetMemberCount { get; init; } + public int RemovedReportControlCount { get; init; } + public int RemovedDataSetCount { get; init; } + public IReadOnlyList Findings { get; init; } = Array.Empty(); +} + +public static class LegacySasSclExporter +{ + private static readonly XNamespace Scl = "http://www.iec.ch/61850/2003/SCL"; + private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web) { WriteIndented = true }; + + public static LegacySasSclExportResult Build( + XDocument source, + string sourceName, + LegacySasSclExportOptions options) + { + ArgumentNullException.ThrowIfNull(source); + ArgumentNullException.ThrowIfNull(options); + if (string.IsNullOrWhiteSpace(options.SelectedReportControl.SelectionKey)) + throw new InvalidOperationException("Legacy SAS export requires exactly one selected ReportControl."); + + var normalized = InteroperableSclConverter.Convert( + source, + sourceName, + new InteroperableSclConversionOptions + { + IedName = options.IedName, + PreserveAllIeds = false, + RemoveExternalInputs = true, + RemoveUnusedTypeTemplates = true, + RemoveSubstationSection = true, + ToolId = options.ToolId + }); + var filtered = SclReportControlFilter.Filter( + normalized.Document, + new SclReportControlFilterOptions + { + IedName = normalized.SelectedIedName, + AccessPointName = options.AccessPointName, + SelectedReportControls = new[] { options.SelectedReportControl }, + RequireExactlyOneReportControl = true, + RemoveUnreferencedDataSets = options.RemoveUnreferencedDataSets, + CollapseIndexedSelectionToSingleInstance = true + }, + sourceName); + + var document = new XDocument(filtered.Document); + var root = document.Root ?? throw new InvalidDataException("Filtered SCL document has no root element."); + var schema = SclSchemaProfiles.Get(options.SchemaProfile); + ApplySchemaProfile(root, schema); + Validate(document, normalized.SelectedIedName); + + var retained = AssertSingleRetained(filtered); + var findings = normalized.Findings + .Concat(filtered.Findings) + .Append(new InteroperableSclFinding + { + Severity = "Info", + Code = "SCL.LEGACY_SAS_EXPORT_READY", + Reference = retained.DisplayReference, + Message = $"Prepared a single-RCB {schema.DisplayName} capability document for deterministic legacy SAS import." + }) + .GroupBy(item => $"{item.Severity}|{item.Code}|{item.Reference}|{item.Message}", StringComparer.OrdinalIgnoreCase) + .Select(group => group.First()) + .ToArray(); + + return new LegacySasSclExportResult + { + Document = document, + IedName = normalized.SelectedIedName, + AccessPointName = retained.AccessPointName, + SclSchema = schema.DisplayName, + RetainedReportControlReference = retained.DisplayReference, + RetainedDataSetName = retained.DataSetName, + RetainedDataSetMemberCount = retained.DataSetMemberCount, + RemovedReportControlCount = filtered.RemovedReportControlCount, + RemovedDataSetCount = filtered.RemovedDataSetCount, + Findings = findings + }; + } + + public static LegacySasSclExportResult WriteFiles( + string inputPath, + string outputPath, + LegacySasSclExportOptions options) + { + ArgumentException.ThrowIfNullOrWhiteSpace(inputPath); + ArgumentException.ThrowIfNullOrWhiteSpace(outputPath); + using var input = File.OpenRead(inputPath); + var source = XDocument.Load(input, LoadOptions.PreserveWhitespace | LoadOptions.SetLineInfo); + var built = Build(source, Path.GetFileName(inputPath), options); + var fullOutputPath = Path.GetFullPath(outputPath); + var directory = Path.GetDirectoryName(fullOutputPath); + if (!string.IsNullOrWhiteSpace(directory)) + Directory.CreateDirectory(directory); + + using (var stream = File.Create(fullOutputPath)) + using (var writer = XmlWriter.Create(stream, new XmlWriterSettings + { + Encoding = new UTF8Encoding(false), + Indent = true, + OmitXmlDeclaration = false + })) + { + built.Document.Save(writer); + } + + var reportPath = Path.ChangeExtension(fullOutputPath, ".legacy-sas-rcb-report.json"); + var summaryPath = Path.ChangeExtension(fullOutputPath, ".legacy-sas-rcb-summary.md"); + var written = built with + { + InputPath = Path.GetFullPath(inputPath), + OutputPath = fullOutputPath, + ReportPath = reportPath, + SummaryPath = summaryPath + }; + File.WriteAllText(reportPath, JsonSerializer.Serialize(written, JsonOptions), new UTF8Encoding(false)); + File.WriteAllText(summaryPath, BuildMarkdown(written), new UTF8Encoding(false)); + return written; + } + + private static SclReportControlDescriptor AssertSingleRetained(SclReportControlFilterResult filtered) + { + if (filtered.RetainedReportControls.Count != 1) + throw new InvalidDataException($"Legacy SAS export must retain exactly one ReportControl; found {filtered.RetainedReportControls.Count}."); + var retained = filtered.RetainedReportControls[0]; + if (!retained.HasPopulatedDataSet) + throw new InvalidDataException("The retained ReportControl does not reference a populated DataSet."); + return retained; + } + + private static void ApplySchemaProfile(XElement root, SclSchemaProfileDescriptor schema) + { + root.SetAttributeValue("version", schema.RootVersion); + root.SetAttributeValue("revision", schema.RootRevision); + if (!schema.SupportsTriggerGi) + { + foreach (var triggerOptions in root.Descendants(Scl + "TrgOps")) + triggerOptions.SetAttributeValue("gi", null); + } + if (!schema.IsEdition2) + { + foreach (var confReportControl in root.Descendants(Scl + "ConfReportControl")) + confReportControl.SetAttributeValue("bufConf", null); + } + if (!schema.SupportsReservationTime) + { + foreach (var reportSettings in root.Descendants(Scl + "ReportSettings")) + { + reportSettings.SetAttributeValue("owner", null); + reportSettings.SetAttributeValue("resvTms", null); + } + foreach (var service in root.Descendants().Where(element => element.Name.LocalName is "SGEdit" or "ConfSG")) + service.SetAttributeValue("resvTms", null); + } + } + + private static void Validate(XDocument document, string iedName) + { + var parsed = new SclParser().Parse(document, "legacy-sas.cid"); + if (!parsed.Ieds.Any(item => item.Name.Equals(iedName, StringComparison.OrdinalIgnoreCase))) + throw new InvalidDataException($"Filtered SCL validation lost IED '{iedName}'."); + if (parsed.ReportControls.Count != 1) + throw new InvalidDataException($"Filtered SCL validation expected one ReportControl, found {parsed.ReportControls.Count}."); + var retained = parsed.ReportControls[0]; + if (retained.DataSetBindingStatus != SclDataSetBindingStatus.Resolved || retained.Entries.Count == 0) + throw new InvalidDataException($"Filtered SCL validation found an unresolved or empty DataSet for '{retained.ControlBlockReference}'."); + } + + private static string BuildMarkdown(LegacySasSclExportResult result) + { + var builder = new StringBuilder(); + builder.AppendLine("# Legacy SAS Selected-RCB Export"); + builder.AppendLine(); + builder.AppendLine($"- Generated: {result.GeneratedAtUtc:yyyy-MM-dd HH:mm:ss.fff} UTC"); + builder.AppendLine($"- Input: `{result.InputPath}`"); + builder.AppendLine($"- Output: `{result.OutputPath}`"); + builder.AppendLine($"- IED / AccessPoint: `{result.IedName}` / `{result.AccessPointName}`"); + builder.AppendLine($"- Schema: `{result.SclSchema}`"); + builder.AppendLine($"- Retained RCB: `{result.RetainedReportControlReference}`"); + builder.AppendLine($"- DataSet: `{result.RetainedDataSetName}` ({result.RetainedDataSetMemberCount} FCDA)"); + builder.AppendLine($"- Removed RCBs: {result.RemovedReportControlCount}"); + builder.AppendLine($"- Removed unreferenced DataSets: {result.RemovedDataSetCount}"); + builder.AppendLine(); + builder.AppendLine("The original source file was not modified. Validate the generated CID with the target SAS import workflow before operational use."); + if (result.Findings.Count > 0) + { + builder.AppendLine(); + builder.AppendLine("## Findings"); + builder.AppendLine(); + foreach (var finding in result.Findings) + builder.AppendLine($"- **{finding.Severity} / {finding.Code}**{(string.IsNullOrWhiteSpace(finding.Reference) ? string.Empty : $" `{finding.Reference}`")}: {finding.Message}"); + } + return builder.ToString(); + } +} From 4d664d85a594a4ee868a68c252d54b4ed0c3fa24 Mon Sep 17 00:00:00 2001 From: masarray Date: Sun, 19 Jul 2026 20:51:33 +0700 Subject: [PATCH 7/9] Test conservative RCB availability evaluation --- .../Mms/MmsRcbAvailabilityTests.cs | 128 ++++++++++++++++++ 1 file changed, 128 insertions(+) create mode 100644 tests/AR.Iec61850.Tests/Mms/MmsRcbAvailabilityTests.cs diff --git a/tests/AR.Iec61850.Tests/Mms/MmsRcbAvailabilityTests.cs b/tests/AR.Iec61850.Tests/Mms/MmsRcbAvailabilityTests.cs new file mode 100644 index 00000000..0605682b --- /dev/null +++ b/tests/AR.Iec61850.Tests/Mms/MmsRcbAvailabilityTests.cs @@ -0,0 +1,128 @@ +using AR.Iec61850.Mms; + +namespace AR.Iec61850.Tests.Mms; + +public sealed class MmsRcbAvailabilityTests +{ + [Fact] + public void Evaluate_Requires_Explicit_Free_Runtime_State_For_Green_Available() + { + var rcb = Candidate(buffered: false); + rcb.EnabledState = "false"; + rcb.ReservationState = "false"; + + var result = MmsRcbAvailabilityEvaluator.Evaluate(rcb, PopulatedDirectory(), callerOwned: false); + + Assert.Equal(MmsRcbOperationalAvailability.Available, result.Availability); + Assert.Equal(MmsRcbAvailabilityConfidence.Exact, result.Confidence); + Assert.True(result.IsSelectable); + Assert.Equal(2, result.DataSetMemberCount); + } + + [Theory] + [InlineData("true", "false", "0", "")] + [InlineData("false", "true", "0", "")] + [InlineData("false", "false", "30", "")] + [InlineData("false", "false", "0", "01020304")] + public void Evaluate_Marks_Runtime_Ownership_Evidence_InUse( + string rptEna, + string resv, + string resvTms, + string owner) + { + var rcb = Candidate(buffered: resvTms == "30"); + rcb.EnabledState = rptEna; + rcb.ReservationState = resv; + rcb.ReservationTimeSeconds = resvTms; + rcb.Owner = owner; + + var result = MmsRcbAvailabilityEvaluator.Evaluate(rcb, PopulatedDirectory(), callerOwned: false); + + Assert.Equal(MmsRcbOperationalAvailability.InUse, result.Availability); + Assert.False(result.IsSelectable); + } + + [Fact] + public void Evaluate_Does_Not_Claim_Edition1_Brcb_Available_When_Reservation_Is_Not_Exposed() + { + var rcb = Candidate(buffered: true); + rcb.EnabledState = "false"; + rcb.ReservationTimeSeconds = string.Empty; + + var result = MmsRcbAvailabilityEvaluator.Evaluate(rcb, PopulatedDirectory(), callerOwned: false); + + Assert.Equal(MmsRcbOperationalAvailability.Unknown, result.Availability); + Assert.Equal(MmsRcbAvailabilityConfidence.Reduced, result.Confidence); + Assert.Contains("does not expose enough reservation evidence", result.Reason, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void Evaluate_Caller_Ownership_Is_Distinguished_From_Other_Client() + { + var rcb = Candidate(buffered: true); + rcb.EnabledState = "true"; + + var result = MmsRcbAvailabilityEvaluator.Evaluate(rcb, PopulatedDirectory(), callerOwned: true); + + Assert.Equal(MmsRcbOperationalAvailability.UsedByCaller, result.Availability); + Assert.True(result.IsSelectable); + } + + [Fact] + public void Evaluate_Blocks_Missing_Empty_And_Unreadable_DataSets() + { + var noDataSet = Candidate(buffered: false); + noDataSet.DataSetReference = string.Empty; + noDataSet.EnabledState = "false"; + noDataSet.ReservationState = "false"; + Assert.Equal( + MmsRcbOperationalAvailability.NoDataSet, + MmsRcbAvailabilityEvaluator.Evaluate(noDataSet, null, false).Availability); + + var empty = Candidate(buffered: false); + empty.EnabledState = "false"; + empty.ReservationState = "false"; + Assert.Equal( + MmsRcbOperationalAvailability.DataSetEmpty, + MmsRcbAvailabilityEvaluator.Evaluate(empty, new MmsDataSetDirectoryResult + { + IsSuccess = true, + DataSetReference = empty.DataSetReference, + Members = Array.Empty() + }, false).Availability); + + Assert.Equal( + MmsRcbOperationalAvailability.DataSetUnreadable, + MmsRcbAvailabilityEvaluator.Evaluate(empty, new MmsDataSetDirectoryResult + { + IsSuccess = false, + DataSetReference = empty.DataSetReference, + Message = "access denied" + }, false).Availability); + } + + private static MmsReportControlCandidate Candidate(bool buffered) + => new() + { + Domain = "IED1LD0", + LogicalNode = "LLN0", + FunctionalConstraint = buffered ? "BR" : "RP", + Name = buffered ? "BRCB01" : "URCB01", + Reference = buffered ? "IED1LD0/LLN0.BR.BRCB01" : "IED1LD0/LLN0.RP.URCB01", + Buffered = buffered, + DataSetReference = "IED1LD0/LLN0.Events", + Status = "Attribute-probed" + }; + + private static MmsDataSetDirectoryResult PopulatedDirectory() + => new() + { + IsSuccess = true, + DataSetReference = "IED1LD0/LLN0.Events", + Members = new[] + { + new MmsDataSetDirectoryMember { UserReference = "IED1LD0/XCBR1.Pos.stVal" }, + new MmsDataSetDirectoryMember { UserReference = "IED1LD0/XCBR1.Pos.q" } + } + }; +} From 1abf7883a13a5fd72b24d38500da3505091487fc Mon Sep 17 00:00:00 2001 From: masarray Date: Sun, 19 Jul 2026 20:52:31 +0700 Subject: [PATCH 8/9] Test selected-RCB filtering and Edition 1 export --- .../Scl/SclReportControlFilterTests.cs | 213 ++++++++++++++++++ 1 file changed, 213 insertions(+) create mode 100644 tests/AR.Iec61850.Tests/Scl/SclReportControlFilterTests.cs diff --git a/tests/AR.Iec61850.Tests/Scl/SclReportControlFilterTests.cs b/tests/AR.Iec61850.Tests/Scl/SclReportControlFilterTests.cs new file mode 100644 index 00000000..49001e2a --- /dev/null +++ b/tests/AR.Iec61850.Tests/Scl/SclReportControlFilterTests.cs @@ -0,0 +1,213 @@ +using System.Xml.Linq; +using AR.Iec61850.Discovery; +using AR.Iec61850.Scl; +using AR.Iec61850.Scl.Export; + +namespace AR.Iec61850.Tests.Scl; + +public sealed class SclReportControlFilterTests +{ + private static readonly XNamespace Scl = "http://www.iec.ch/61850/2003/SCL"; + + [Fact] + public void Inspect_Provides_Stable_Keys_Type_And_DataSet_Member_Count() + { + var source = XDocument.Parse(Fixture()); + + var inventory = SclReportControlFilter.Inspect(source, "IED1.cid", "IED1", "AP1"); + + Assert.Equal(3, inventory.ReportControls.Count); + var first = inventory.ReportControls.First(item => item.Name == "BRCB_EVENTS"); + Assert.Equal("Buffered", first.Type); + Assert.Equal("Events", first.DataSetName); + Assert.Equal(2, first.DataSetMemberCount); + Assert.True(first.HasPopulatedDataSet); + Assert.Contains("IED1|AP1|LD0|LLN0|BRCB_EVENTS", first.SelectionKey, StringComparison.Ordinal); + Assert.Contains(inventory.Findings, finding => finding.Code == "SCL.REPORT_DATASET_EMPTY"); + } + + [Fact] + public void Filter_Retains_Exactly_One_Rcb_And_Does_Not_Mutate_Source() + { + var source = XDocument.Parse(Fixture()); + var original = source.ToString(SaveOptions.DisableFormatting); + var descriptor = SclReportControlFilter.Inspect(source, "IED1.cid", "IED1", "AP1") + .ReportControls.Single(item => item.Name == "URCB_STATUS"); + + var result = SclReportControlFilter.Filter( + source, + new SclReportControlFilterOptions + { + IedName = "IED1", + AccessPointName = "AP1", + SelectedReportControls = new[] { new SclReportControlSelection(descriptor.SelectionKey) }, + RequireExactlyOneReportControl = true + }, + "IED1.cid"); + + Assert.Equal(original, source.ToString(SaveOptions.DisableFormatting)); + var retained = Assert.Single(result.Document.Descendants(Scl + "ReportControl")); + Assert.Equal("URCB_STATUS", (string?)retained.Attribute("name")); + Assert.Equal(2, result.RemovedReportControlCount); + Assert.Equal(0, result.RemovedDataSetCount); + Assert.Equal(2, result.Document.Descendants(Scl + "DataSet").Count()); + Assert.Single(new SclParser().Parse(result.Document, "filtered.cid").ReportControls); + } + + [Fact] + public void Filter_Collapses_Indexed_Rcb_To_Exact_Runtime_Name_And_Max_One() + { + var source = XDocument.Parse(Fixture()); + var descriptor = SclReportControlFilter.Inspect(source, "IED1.cid", "IED1", "AP1") + .ReportControls.Single(item => item.Name == "BRCB_EVENTS"); + + var result = SclReportControlFilter.Filter( + source, + new SclReportControlFilterOptions + { + IedName = "IED1", + AccessPointName = "AP1", + SelectedReportControls = new[] + { + new SclReportControlSelection(descriptor.SelectionKey, "BRCB_EVENTS03") + } + }, + "IED1.cid"); + + var retained = Assert.Single(result.Document.Descendants(Scl + "ReportControl")); + Assert.Equal("BRCB_EVENTS03", (string?)retained.Attribute("name")); + Assert.Equal("false", (string?)retained.Attribute("indexed")); + Assert.Equal("1", (string?)Assert.Single(retained.Elements(Scl + "RptEnabled")).Attribute("max")); + } + + [Fact] + public void Filter_Rejects_Missing_Or_Empty_DataSet() + { + var source = XDocument.Parse(Fixture()); + var inventory = SclReportControlFilter.Inspect(source, "IED1.cid", "IED1", "AP1"); + var empty = inventory.ReportControls.Single(item => item.Name == "URCB_EMPTY"); + + var error = Assert.Throws(() => SclReportControlFilter.Filter( + source, + new SclReportControlFilterOptions + { + IedName = "IED1", + AccessPointName = "AP1", + SelectedReportControls = new[] { new SclReportControlSelection(empty.SelectionKey) } + }, + "IED1.cid")); + + Assert.Contains("empty DataSet", error.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void LegacyExporter_Applies_Edition1_Profile_And_Filter_With_Evidence() + { + var source = XDocument.Parse(Fixture()); + var descriptor = SclReportControlFilter.Inspect(source, "IED1.cid", "IED1", "AP1") + .ReportControls.Single(item => item.Name == "URCB_STATUS"); + + var result = LegacySasSclExporter.Build( + source, + "IED1.cid", + new LegacySasSclExportOptions + { + IedName = "IED1", + AccessPointName = "AP1", + SchemaProfile = SclSchemaProfile.Edition1V16, + SelectedReportControl = new SclReportControlSelection(descriptor.SelectionKey) + }); + + Assert.Equal("Edition 1 (Schema V1.6)", result.SclSchema); + Assert.Null(result.Document.Root!.Attribute("version")); + Assert.Null(result.Document.Root.Attribute("revision")); + Assert.Single(result.Document.Descendants(Scl + "ReportControl")); + Assert.All(result.Document.Descendants(Scl + "TrgOps"), element => Assert.Null(element.Attribute("gi"))); + Assert.Equal(2, result.RemovedReportControlCount); + Assert.Equal("Events", result.RetainedDataSetName); + Assert.DoesNotContain(result.Findings, finding => finding.Severity.Equals("Error", StringComparison.OrdinalIgnoreCase)); + } + + [Fact] + public void FilterLiveModel_Retains_One_Rcb_And_Validates_DataSet() + { + var dataSet = new LiveIedDataSetModel + { + Reference = "IED1LD0/LLN0.Events", + Name = "Events", + MemberCount = 1, + Members = new[] { new LiveIedDataSetMemberModel { Reference = "IED1LD0/XCBR1.Pos.stVal" } } + }; + var selected = new LiveIedReportControlModel + { + Reference = "IED1LD0/LLN0.BR.BRCB01", + Domain = "IED1LD0", + LogicalNode = "LLN0", + Name = "BRCB01", + Buffered = true, + DataSetReference = dataSet.Reference + }; + var source = new LiveIedModelDiscoveryDocument + { + IedName = "IED1", + DataSets = new[] { dataSet }, + ReportControls = new[] + { + selected, + new LiveIedReportControlModel + { + Reference = "IED1LD0/LLN0.RP.URCB01", + Domain = "IED1LD0", + LogicalNode = "LLN0", + Name = "URCB01", + DataSetReference = dataSet.Reference + } + }, + Coverage = new LiveIedModelDiscoveryCoverage + { + ReportControlCount = 2, + BufferedReportControlCount = 1, + UnbufferedReportControlCount = 1, + DataSetCount = 1 + } + }; + + var filtered = SclReportControlFilter.FilterLiveModel(source, selected.Reference); + + Assert.Same(selected, Assert.Single(filtered.ReportControls)); + Assert.Equal(1, filtered.Coverage.ReportControlCount); + Assert.Equal(1, filtered.Coverage.BufferedReportControlCount); + Assert.Equal(0, filtered.Coverage.UnbufferedReportControlCount); + Assert.Equal(2, source.ReportControls.Count); + } + + private static string Fixture() + => """ + + +
+ + + + + + + + + + + + + + + + + + + + + + + + """; +} From 0fdc2a857a45b48a8ac2981601f400e9981f1d6f Mon Sep 17 00:00:00 2001 From: masarray Date: Sun, 19 Jul 2026 20:55:11 +0700 Subject: [PATCH 9/9] Remove temporary source snapshot workflow --- .github/workflows/source-snapshot-temp.yml | 21 --------------------- 1 file changed, 21 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 4b4ab465..00000000 --- a/.github/workflows/source-snapshot-temp.yml +++ /dev/null @@ -1,21 +0,0 @@ -name: Temporary source snapshot - -on: - pull_request: - branches: [ main ] - workflow_dispatch: - -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