diff --git a/src/AR.Iec61850/Scl/Export/AuthoritativeLiveIedSclExporter.cs b/src/AR.Iec61850/Scl/Export/AuthoritativeLiveIedSclExporter.cs new file mode 100644 index 0000000..499a99a --- /dev/null +++ b/src/AR.Iec61850/Scl/Export/AuthoritativeLiveIedSclExporter.cs @@ -0,0 +1,156 @@ +using System.Xml.Linq; +using AR.Iec61850.Discovery; + +namespace AR.Iec61850.Scl.Export; + +/// +/// Exports a live-discovery model while keeping the physical IED identity separate from +/// communication-level MMS Logical Device domain names. +/// +public static class AuthoritativeLiveIedSclExporter +{ + private static readonly XNamespace Scl = "http://www.iec.ch/61850/2003/SCL"; + + public static LiveIedSclExportResult WriteFiles( + LiveIedModelDiscoveryDocument model, + string sclPath, + LiveIedSclExportOptions? options = null) + { + ArgumentNullException.ThrowIfNull(model); + options ??= new LiveIedSclExportOptions(); + + var result = LiveIedSclExporter.WriteFiles(model, sclPath, options); + if (string.IsNullOrWhiteSpace(options.IedNameOverride)) + return result; + + var document = XDocument.Load(result.SclPath, LoadOptions.PreserveWhitespace); + ApplyIdentity(document, model, options.IedNameOverride); + document.Save(result.SclPath); + return result; + } + + public static XDocument ApplyIdentity( + XDocument source, + LiveIedModelDiscoveryDocument model, + string authoritativeIedName) + { + ArgumentNullException.ThrowIfNull(source); + ArgumentNullException.ThrowIfNull(model); + if (string.IsNullOrWhiteSpace(authoritativeIedName)) + throw new ArgumentException("Authoritative IED name is empty.", nameof(authoritativeIedName)); + + var document = new XDocument(source); + var root = document.Root ?? throw new InvalidDataException("Generated SCL document has no root element."); + var safeIedName = SafeXmlName(authoritativeIedName); + + var ied = root.Elements(Scl + "IED").SingleOrDefault() + ?? throw new InvalidDataException("Generated live SCL must contain exactly one IED element."); + ied.SetAttributeValue("name", safeIedName); + + foreach (var connectedAp in root.Descendants(Scl + "ConnectedAP")) + connectedAp.SetAttributeValue("iedName", safeIedName); + + var header = root.Element(Scl + "Header"); + header?.SetAttributeValue("id", $"{safeIedName}_GENERATED"); + + var logicalDevices = ied.Descendants(Scl + "LDevice").ToArray(); + var unmatchedDomains = new HashSet( + model.LogicalDevices + .Select(LogicalDeviceDomain) + .Where(domain => !string.IsNullOrWhiteSpace(domain)), + StringComparer.OrdinalIgnoreCase); + + foreach (var logicalDevice in logicalDevices) + { + var inst = ((string?)logicalDevice.Attribute("inst") ?? string.Empty).Trim(); + if (string.IsNullOrWhiteSpace(inst)) + continue; + + var domain = MatchMmsDomain(inst, model.IedName, unmatchedDomains); + if (string.IsNullOrWhiteSpace(domain)) + continue; + + unmatchedDomains.Remove(domain); + var implicitName = $"{safeIedName}{inst}"; + logicalDevice.SetAttributeValue( + "ldName", + domain.Equals(implicitName, StringComparison.OrdinalIgnoreCase) ? null : domain); + } + + ValidateIdentity(document, safeIedName, model); + return document; + } + + private static string MatchMmsDomain( + string generatedInst, + string previousIedName, + IReadOnlySet domains) + { + var direct = domains.FirstOrDefault(domain => + domain.Equals(generatedInst, StringComparison.OrdinalIgnoreCase)); + if (!string.IsNullOrWhiteSpace(direct)) + return direct; + + var previousImplicit = $"{previousIedName?.Trim()}{generatedInst}"; + var implicitMatch = domains.FirstOrDefault(domain => + domain.Equals(previousImplicit, StringComparison.OrdinalIgnoreCase)); + if (!string.IsNullOrWhiteSpace(implicitMatch)) + return implicitMatch; + + return string.Empty; + } + + private static void ValidateIdentity( + XDocument document, + string authoritativeIedName, + LiveIedModelDiscoveryDocument model) + { + var root = document.Root ?? throw new InvalidDataException("Normalized SCL has no root element."); + var ied = root.Elements(Scl + "IED").Single(); + if (!authoritativeIedName.Equals((string?)ied.Attribute("name"), StringComparison.Ordinal)) + throw new InvalidDataException("Normalized SCL IED identity does not match the authoritative IED name."); + + if (root.Descendants(Scl + "ConnectedAP").Any(ap => + !authoritativeIedName.Equals((string?)ap.Attribute("iedName"), StringComparison.Ordinal))) + { + throw new InvalidDataException("ConnectedAP identity is inconsistent with the normalized IED name."); + } + + var exportedCommunicationNames = root.Descendants(Scl + "LDevice") + .Select(ld => + { + var explicitName = ((string?)ld.Attribute("ldName") ?? string.Empty).Trim(); + var inst = ((string?)ld.Attribute("inst") ?? string.Empty).Trim(); + return string.IsNullOrWhiteSpace(explicitName) + ? $"{authoritativeIedName}{inst}" + : explicitName; + }) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + + var missingDomains = model.LogicalDevices + .Select(LogicalDeviceDomain) + .Where(domain => !exportedCommunicationNames.Contains(domain)) + .ToArray(); + if (missingDomains.Length > 0) + { + throw new InvalidDataException( + $"Normalized SCL lost MMS Logical Device domain(s): {string.Join(", ", missingDomains)}."); + } + } + + private static string LogicalDeviceDomain(LiveIedLogicalDeviceModel logicalDevice) + => string.IsNullOrWhiteSpace(logicalDevice.MmsDomain) + ? logicalDevice.Inst.Trim() + : logicalDevice.MmsDomain.Trim(); + + private static string SafeXmlName(string value) + { + var chars = value.Trim() + .Select(character => char.IsLetterOrDigit(character) || character is '_' or '-' ? character : '_') + .ToArray(); + var result = new string(chars); + if (string.IsNullOrWhiteSpace(result)) + return "LIVE_IED"; + return char.IsLetter(result[0]) || result[0] == '_' ? result : $"_{result}"; + } +} diff --git a/src/AR.Iec61850/Scl/Export/LiveIedSclExportModels.cs b/src/AR.Iec61850/Scl/Export/LiveIedSclExportModels.cs index 3a233df..69dff6a 100644 --- a/src/AR.Iec61850/Scl/Export/LiveIedSclExportModels.cs +++ b/src/AR.Iec61850/Scl/Export/LiveIedSclExportModels.cs @@ -9,6 +9,12 @@ public sealed class LiveIedSclExportOptions public SclSchemaProfile SchemaProfile { get; init; } = SclSchemaProfile.Edition2V31; public string SubNetworkName { get; init; } = "StationBus"; public string IpAddress { get; init; } = string.Empty; + /// + /// Authoritative SCL IED identity supplied by the caller. MMS exposes Logical Device + /// domain names, which are not a reliable source for recovering the enclosing IED name + /// when an implementation uses explicit communication-level LD names. + /// + public string IedNameOverride { get; init; } = string.Empty; public string IpSubnet { get; init; } = "255.255.255.0"; public string IpGateway { get; init; } = "0.0.0.0"; public string OsiApTitle { get; init; } = string.Empty; diff --git a/tests/AR.Iec61850.Tests/Scl/LiveIedSclIedIdentityTests.cs b/tests/AR.Iec61850.Tests/Scl/LiveIedSclIedIdentityTests.cs new file mode 100644 index 0000000..a89c62f --- /dev/null +++ b/tests/AR.Iec61850.Tests/Scl/LiveIedSclIedIdentityTests.cs @@ -0,0 +1,134 @@ +using AR.Iec61850.Discovery; +using AR.Iec61850.Scl.Export; +using System.Xml.Linq; + +namespace AR.Iec61850.Tests.Scl; + +public sealed class LiveIedSclIedIdentityTests +{ + [Fact] + public void Exporter_Uses_Authoritative_Ied_Name_And_Preserves_Explicit_Mms_Domains() + { + var model = new LiveIedModelDiscoveryDocument + { + Host = "1.110.1.1", + // Reproduces the unsafe legacy heuristic: the first MMS domain was copied as IED name. + IedName = "OCR7SJ8Application", + AccessPointName = "AP1", + LogicalDevices = + [ + LogicalDevice("OCR7SJ8Application"), + LogicalDevice("OCR7SJ8CB1") + ], + DataSets = + [ + new LiveIedDataSetModel + { + Reference = "OCR7SJ8Application/LLN0.DataSet", + Domain = "OCR7SJ8Application", + LogicalNode = "LLN0", + Name = "DataSet", + MemberCount = 1, + Members = + [ + new LiveIedDataSetMemberModel + { + Index = 0, + Reference = "OCR7SJ8CB1/XCBR1.Pos.stVal", + FunctionalConstraint = "ST" + } + ] + } + ], + ReportControls = + [ + new LiveIedReportControlModel + { + Reference = "OCR7SJ8Application/LLN0.RP.urcbD0101", + Domain = "OCR7SJ8Application", + LogicalNode = "LLN0", + Name = "urcbD0101", + Buffered = false, + DataSetReference = "OCR7SJ8Application/LLN0.DataSet", + ConfRev = "1" + } + ] + }; + + var generated = LiveIedSclExporter.BuildDocument( + model, + new LiveIedSclExportOptions + { + Profile = "full-model", + SchemaProfile = SclSchemaProfile.Edition1V16, + IpAddress = "1.110.1.1" + }); + var document = AuthoritativeLiveIedSclExporter.ApplyIdentity( + generated, + model, + "OCR7SJ8Mod2"); + + var root = Assert.IsType(document.Root); + var ns = root.Name.Namespace; + var ied = Assert.Single(root.Elements(ns + "IED")); + Assert.Equal("OCR7SJ8Mod2", (string?)ied.Attribute("name")); + Assert.Equal("OCR7SJ8Mod2", (string?)root.Descendants(ns + "ConnectedAP").Single().Attribute("iedName")); + + var logicalDevices = root.Descendants(ns + "LDevice").ToArray(); + Assert.Equal(2, logicalDevices.Length); + Assert.Contains(logicalDevices, ld => + (string?)ld.Attribute("inst") == "OCR7SJ8Application" && + (string?)ld.Attribute("ldName") == "OCR7SJ8Application"); + Assert.Contains(logicalDevices, ld => + (string?)ld.Attribute("inst") == "OCR7SJ8CB1" && + (string?)ld.Attribute("ldName") == "OCR7SJ8CB1"); + + var report = Assert.Single(root.Descendants(ns + "ReportControl")); + Assert.Equal("urcbD0101", (string?)report.Attribute("name")); + var fcda = Assert.Single(root.Descendants(ns + "FCDA")); + Assert.Equal("OCR7SJ8CB1", (string?)fcda.Attribute("ldInst")); + Assert.DoesNotContain("OCR7SJ8Mod2OCR7SJ8Application", document.ToString(), StringComparison.Ordinal); + } + + [Fact] + public void Exporter_Keeps_Implicit_Product_Naming_When_Domain_Starts_With_Ied_Name() + { + var model = new LiveIedModelDiscoveryDocument + { + IedName = "IED1", + LogicalDevices = [LogicalDevice("IED1PROT")] + }; + + var generated = LiveIedSclExporter.BuildDocument(model, new LiveIedSclExportOptions()); + var document = AuthoritativeLiveIedSclExporter.ApplyIdentity(generated, model, "IED1"); + var root = Assert.IsType(document.Root); + var ns = root.Name.Namespace; + var logicalDevice = Assert.Single(root.Descendants(ns + "LDevice")); + + Assert.Equal("PROT", (string?)logicalDevice.Attribute("inst")); + Assert.Null(logicalDevice.Attribute("ldName")); + } + + private static LiveIedLogicalDeviceModel LogicalDevice(string domain) + => new() + { + MmsDomain = domain, + Inst = domain, + LogicalNodes = + [ + new LiveIedLogicalNodeModel + { + Name = "LLN0", + LnClass = "LLN0", + ProposedLnTypeId = $"LN_{domain}_LLN0" + }, + new LiveIedLogicalNodeModel + { + Name = "XCBR1", + LnClass = "XCBR", + LnInst = "1", + ProposedLnTypeId = $"LN_{domain}_XCBR1" + } + ] + }; +}