From a98be110ddbfdbadd79ea88022fa3ba8acd33876 Mon Sep 17 00:00:00 2001 From: masarray Date: Tue, 21 Jul 2026 16:08:13 +0700 Subject: [PATCH 1/5] Normalize live RCB CID to exact non-indexed instance name --- .../Export/LiveRcbSclInstanceNormalizer.cs | 133 ++++++++++++++++++ 1 file changed, 133 insertions(+) create mode 100644 src/AR.Iec61850/Scl/Export/LiveRcbSclInstanceNormalizer.cs diff --git a/src/AR.Iec61850/Scl/Export/LiveRcbSclInstanceNormalizer.cs b/src/AR.Iec61850/Scl/Export/LiveRcbSclInstanceNormalizer.cs new file mode 100644 index 0000000..66dec40 --- /dev/null +++ b/src/AR.Iec61850/Scl/Export/LiveRcbSclInstanceNormalizer.cs @@ -0,0 +1,133 @@ +using System.Text; +using System.Xml; +using System.Xml.Linq; + +namespace AR.Iec61850.Scl.Export; + +/// +/// Normalizes a selected live MMS RCB instance into a one-instance SCL ReportControl. +/// Live discovery already exposes the concrete MMS object name (for example +/// A_BRCB_1201). In SCL, ReportControl@indexed defaults to true; leaving it omitted +/// causes an importer to append a second two-digit instance suffix and incorrectly +/// address A_BRCB_120101. A concrete live instance is therefore represented with +/// its exact name, indexed=false and RptEnabled max=1. +/// +public static class LiveRcbSclInstanceNormalizer +{ + private static readonly XNamespace Scl = "http://www.iec.ch/61850/2003/SCL"; + + public static LiveRcbSclInstanceNormalizationResult NormalizeFile( + string sclPath, + string exactRuntimeRcbName) + { + ArgumentException.ThrowIfNullOrWhiteSpace(sclPath); + using var input = File.OpenRead(sclPath); + var document = XDocument.Load(input, LoadOptions.PreserveWhitespace | LoadOptions.SetLineInfo); + var result = Normalize(document, exactRuntimeRcbName); + + using var output = File.Create(sclPath); + using var writer = XmlWriter.Create(output, new XmlWriterSettings + { + Encoding = new UTF8Encoding(false), + Indent = true, + OmitXmlDeclaration = false + }); + document.Save(writer); + return result with { SclPath = Path.GetFullPath(sclPath) }; + } + + public static LiveRcbSclInstanceNormalizationResult Normalize( + XDocument document, + string exactRuntimeRcbName) + { + ArgumentNullException.ThrowIfNull(document); + exactRuntimeRcbName = (exactRuntimeRcbName ?? string.Empty).Trim(); + if (exactRuntimeRcbName.Length == 0) + throw new ArgumentException("The exact runtime RCB name is empty.", nameof(exactRuntimeRcbName)); + + try + { + XmlConvert.VerifyNCName(exactRuntimeRcbName); + } + catch (XmlException ex) + { + throw new InvalidDataException($"Runtime RCB name '{exactRuntimeRcbName}' is not a valid SCL name.", ex); + } + + var reportControls = document + .Descendants() + .Where(element => element.Name.LocalName == "ReportControl") + .ToArray(); + if (reportControls.Length != 1) + { + throw new InvalidDataException( + $"Selected-RCB CID normalization requires exactly one ReportControl; found {reportControls.Length}."); + } + + var reportControl = reportControls[0]; + var previousName = ((string?)reportControl.Attribute("name") ?? string.Empty).Trim(); + reportControl.SetAttributeValue("name", exactRuntimeRcbName); + reportControl.SetAttributeValue("indexed", "false"); + + var rptEnabled = reportControl.Elements().FirstOrDefault(element => element.Name.LocalName == "RptEnabled"); + if (rptEnabled is null) + { + rptEnabled = new XElement(Scl + "RptEnabled"); + reportControl.Add(rptEnabled); + } + rptEnabled.SetAttributeValue("max", "1"); + foreach (var clientLn in rptEnabled.Elements().Where(element => element.Name.LocalName == "ClientLN").ToArray()) + clientLn.Remove(); + + var services = document.Descendants().FirstOrDefault(element => element.Name.LocalName == "ConfReportControl"); + services?.SetAttributeValue("max", "1"); + + Validate(document, exactRuntimeRcbName); + return new LiveRcbSclInstanceNormalizationResult + { + PreviousReportControlName = previousName, + ExactRuntimeReportControlName = exactRuntimeRcbName, + Indexed = false, + InstanceCount = 1 + }; + } + + private static void Validate(XDocument document, string exactRuntimeRcbName) + { + var reportControl = document.Descendants().Single(element => element.Name.LocalName == "ReportControl"); + var actualName = ((string?)reportControl.Attribute("name") ?? string.Empty).Trim(); + if (!actualName.Equals(exactRuntimeRcbName, StringComparison.Ordinal)) + { + throw new InvalidDataException( + $"Selected-RCB CID changed runtime name '{exactRuntimeRcbName}' to '{actualName}'."); + } + + if (!string.Equals((string?)reportControl.Attribute("indexed"), "false", StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidDataException( + $"Concrete runtime RCB '{exactRuntimeRcbName}' must be exported with indexed=false."); + } + + var rptEnabled = reportControl.Elements().SingleOrDefault(element => element.Name.LocalName == "RptEnabled"); + if (rptEnabled is null || !string.Equals((string?)rptEnabled.Attribute("max"), "1", StringComparison.Ordinal)) + { + throw new InvalidDataException( + $"Concrete runtime RCB '{exactRuntimeRcbName}' must expose exactly one instance through RptEnabled max=1."); + } + + if (rptEnabled.Elements().Any(element => element.Name.LocalName == "ClientLN")) + { + throw new InvalidDataException( + $"Concrete runtime RCB '{exactRuntimeRcbName}' must not contain indexed ClientLN assignments."); + } + } +} + +public sealed record LiveRcbSclInstanceNormalizationResult +{ + public string SclPath { get; init; } = string.Empty; + public string PreviousReportControlName { get; init; } = string.Empty; + public string ExactRuntimeReportControlName { get; init; } = string.Empty; + public bool Indexed { get; init; } + public int InstanceCount { get; init; } +} \ No newline at end of file From 4c79ca3af2eef60a9388f66b05fb4d59757684d7 Mon Sep 17 00:00:00 2001 From: masarray Date: Tue, 21 Jul 2026 16:08:44 +0700 Subject: [PATCH 2/5] Add regression for A_BRCB_1201 exact runtime name --- .../Scl/LiveRcbSclInstanceNormalizerTests.cs | 68 +++++++++++++++++++ 1 file changed, 68 insertions(+) create mode 100644 tests/AR.Iec61850.Tests/Scl/LiveRcbSclInstanceNormalizerTests.cs diff --git a/tests/AR.Iec61850.Tests/Scl/LiveRcbSclInstanceNormalizerTests.cs b/tests/AR.Iec61850.Tests/Scl/LiveRcbSclInstanceNormalizerTests.cs new file mode 100644 index 0000000..f3fd82d --- /dev/null +++ b/tests/AR.Iec61850.Tests/Scl/LiveRcbSclInstanceNormalizerTests.cs @@ -0,0 +1,68 @@ +using System.Xml.Linq; +using AR.Iec61850.Scl.Export; + +namespace AR.Iec61850.Tests.Scl; + +public sealed class LiveRcbSclInstanceNormalizerTests +{ + private static readonly XNamespace Scl = "http://www.iec.ch/61850/2003/SCL"; + + [Fact] + public void Normalize_Exports_Exact_Runtime_Name_Without_Appending_Another_01() + { + var document = XDocument.Parse( + """ + + + + + + + + + + + + + + + """); + + var result = LiveRcbSclInstanceNormalizer.Normalize(document, "A_BRCB_1201"); + + var reportControl = Assert.Single(document.Descendants(Scl + "ReportControl")); + Assert.Equal("A_BRCB_1201", (string?)reportControl.Attribute("name")); + Assert.Equal("false", (string?)reportControl.Attribute("indexed")); + Assert.Equal("1", (string?)Assert.Single(reportControl.Elements(Scl + "RptEnabled")).Attribute("max")); + Assert.Equal("1", (string?)Assert.Single(document.Descendants(Scl + "ConfReportControl")).Attribute("max")); + Assert.Equal("A_BRCB_1201", result.ExactRuntimeReportControlName); + + var importedRuntimeName = string.Equals( + (string?)reportControl.Attribute("indexed"), + "true", + StringComparison.OrdinalIgnoreCase) + ? $"{(string?)reportControl.Attribute("name")}01" + : (string?)reportControl.Attribute("name"); + Assert.Equal("A_BRCB_1201", importedRuntimeName); + Assert.DoesNotContain("A_BRCB_120101", document.ToString(SaveOptions.DisableFormatting), StringComparison.Ordinal); + } + + [Fact] + public void Normalize_Rejects_Multiple_ReportControl_Definitions() + { + var document = XDocument.Parse( + """ + + + + + + + """); + + var error = Assert.Throws(() => + LiveRcbSclInstanceNormalizer.Normalize(document, "A_BRCB_1201")); + + Assert.Contains("exactly one ReportControl", error.Message, StringComparison.OrdinalIgnoreCase); + } +} \ No newline at end of file From f1db06e2bb30bce9811f84e31e3d6c82830dcbac Mon Sep 17 00:00:00 2001 From: masarray Date: Tue, 21 Jul 2026 16:11:27 +0700 Subject: [PATCH 3/5] Export live MMS RCB names as exact non-indexed instances --- .../Export/AuthoritativeLiveIedSclExporter.cs | 50 +++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/src/AR.Iec61850/Scl/Export/AuthoritativeLiveIedSclExporter.cs b/src/AR.Iec61850/Scl/Export/AuthoritativeLiveIedSclExporter.cs index 926e0c5..c015f04 100644 --- a/src/AR.Iec61850/Scl/Export/AuthoritativeLiveIedSclExporter.cs +++ b/src/AR.Iec61850/Scl/Export/AuthoritativeLiveIedSclExporter.cs @@ -111,6 +111,20 @@ public static XDocument ApplyReportControlConfiguration( if (modelControl is null) continue; + // MMS discovery returns a concrete RCB object name. IEC 61850-6 defines + // ReportControl@indexed with a default value of true; if the attribute is + // omitted, an engineering tool appends another two-digit instance suffix. + // Therefore A_BRCB_1201 would become the invalid A_BRCB_120101. Preserve + // the proven live object exactly as one non-indexed instance. + element.SetAttributeValue("name", SafeXmlName(modelControl.Name)); + element.SetAttributeValue("indexed", "false"); + var rptEnabled = element.Element(Scl + "RptEnabled") ?? new XElement(Scl + "RptEnabled"); + rptEnabled.SetAttributeValue("max", "1"); + foreach (var clientLn in rptEnabled.Elements(Scl + "ClientLN").ToArray()) + clientLn.Remove(); + if (rptEnabled.Parent is null) + element.Add(rptEnabled); + var trigger = MmsReportControlFieldCodec.DecodeTriggerOptions(modelControl.TriggerOptions); var triggerElement = element.Element(Scl + "TrgOps") ?? new XElement(Scl + "TrgOps"); triggerElement.SetAttributeValue("dchg", XmlBool(trigger.DataChange)); @@ -140,9 +154,45 @@ public static XDocument ApplyReportControlConfiguration( element.Add(optionalElement); } + var confReportControl = document.Descendants(Scl + "ConfReportControl").SingleOrDefault(); + if (confReportControl is not null) + confReportControl.SetAttributeValue("max", reportControls.Count.ToString(System.Globalization.CultureInfo.InvariantCulture)); + + ValidateReportControlIdentity(document, reportControls); return document; } + private static void ValidateReportControlIdentity( + XDocument document, + IReadOnlyCollection reportControls) + { + var exported = document.Descendants(Scl + "ReportControl").ToArray(); + if (exported.Length != reportControls.Count) + { + throw new InvalidDataException( + $"Generated SCL contains {exported.Length} ReportControl element(s), but live discovery contains {reportControls.Count}."); + } + + foreach (var modelControl in reportControls) + { + var matches = exported.Where(element => + string.Equals((string?)element.Attribute("name"), SafeXmlName(modelControl.Name), StringComparison.Ordinal) && + string.Equals((string?)element.Attribute("indexed"), "false", StringComparison.OrdinalIgnoreCase)).ToArray(); + if (matches.Length != 1) + { + throw new InvalidDataException( + $"Live RCB '{modelControl.Name}' was not exported exactly once as indexed=false."); + } + + var rptEnabled = matches[0].Element(Scl + "RptEnabled"); + if (rptEnabled is null || !string.Equals((string?)rptEnabled.Attribute("max"), "1", StringComparison.Ordinal)) + { + throw new InvalidDataException( + $"Live RCB '{modelControl.Name}' must be exported with RptEnabled max=1."); + } + } + } + private static string XmlBool(bool value) => value ? "true" : "false"; private static string MatchMmsDomain( From 11461a838ada03e81216353c590a1b0d7d91fda0 Mon Sep 17 00:00:00 2001 From: masarray Date: Tue, 21 Jul 2026 16:11:53 +0700 Subject: [PATCH 4/5] Cover authoritative live RCB exact-name export --- .../Scl/AuthoritativeLiveRcbNameTests.cs | 59 +++++++++++++++++++ 1 file changed, 59 insertions(+) create mode 100644 tests/AR.Iec61850.Tests/Scl/AuthoritativeLiveRcbNameTests.cs diff --git a/tests/AR.Iec61850.Tests/Scl/AuthoritativeLiveRcbNameTests.cs b/tests/AR.Iec61850.Tests/Scl/AuthoritativeLiveRcbNameTests.cs new file mode 100644 index 0000000..f43aa54 --- /dev/null +++ b/tests/AR.Iec61850.Tests/Scl/AuthoritativeLiveRcbNameTests.cs @@ -0,0 +1,59 @@ +using System.Xml.Linq; +using AR.Iec61850.Discovery; +using AR.Iec61850.Scl.Export; + +namespace AR.Iec61850.Tests.Scl; + +public sealed class AuthoritativeLiveRcbNameTests +{ + private static readonly XNamespace Scl = "http://www.iec.ch/61850/2003/SCL"; + + [Fact] + public void ApplyReportControlConfiguration_Preserves_A_BRCB_1201_Without_Second_Index() + { + var source = XDocument.Parse( + """ + + + + + + + + + + + + + + + """); + var model = new LiveIedModelDiscoveryDocument + { + ReportControls = + [ + new LiveIedReportControlModel + { + Reference = "BCU7SLCTRL/LLN0.BR.A_BRCB_1201", + Domain = "BCU7SLCTRL", + LogicalNode = "LLN0", + Name = "A_BRCB_1201", + Buffered = true, + DataSetReference = "BCU7SLCTRL/LLN0.ARIED_8550BE6C", + ConfRev = "4" + } + ] + }; + + var result = AuthoritativeLiveIedSclExporter.ApplyReportControlConfiguration( + source, + model, + SclSchemaProfiles.Get(SclSchemaProfile.Edition1V16)); + + var reportControl = Assert.Single(result.Descendants(Scl + "ReportControl")); + Assert.Equal("A_BRCB_1201", (string?)reportControl.Attribute("name")); + Assert.Equal("false", (string?)reportControl.Attribute("indexed")); + Assert.Equal("1", (string?)Assert.Single(reportControl.Elements(Scl + "RptEnabled")).Attribute("max")); + Assert.DoesNotContain("A_BRCB_120101", result.ToString(SaveOptions.DisableFormatting), StringComparison.Ordinal); + } +} \ No newline at end of file From e16b53a796fa4c554cba9def6d1e1767a517a3fe Mon Sep 17 00:00:00 2001 From: masarray Date: Tue, 21 Jul 2026 16:15:32 +0700 Subject: [PATCH 5/5] Fix live RCB count serialization --- src/AR.Iec61850/Scl/Export/AuthoritativeLiveIedSclExporter.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/AR.Iec61850/Scl/Export/AuthoritativeLiveIedSclExporter.cs b/src/AR.Iec61850/Scl/Export/AuthoritativeLiveIedSclExporter.cs index c015f04..93ad070 100644 --- a/src/AR.Iec61850/Scl/Export/AuthoritativeLiveIedSclExporter.cs +++ b/src/AR.Iec61850/Scl/Export/AuthoritativeLiveIedSclExporter.cs @@ -156,7 +156,7 @@ public static XDocument ApplyReportControlConfiguration( var confReportControl = document.Descendants(Scl + "ConfReportControl").SingleOrDefault(); if (confReportControl is not null) - confReportControl.SetAttributeValue("max", reportControls.Count.ToString(System.Globalization.CultureInfo.InvariantCulture)); + confReportControl.SetAttributeValue("max", reportControls.Length.ToString(System.Globalization.CultureInfo.InvariantCulture)); ValidateReportControlIdentity(document, reportControls); return document;