Skip to content
156 changes: 156 additions & 0 deletions src/AR.Iec61850/Scl/Export/AuthoritativeLiveIedSclExporter.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
using System.Xml.Linq;
using AR.Iec61850.Discovery;

namespace AR.Iec61850.Scl.Export;

/// <summary>
/// Exports a live-discovery model while keeping the physical IED identity separate from
/// communication-level MMS Logical Device domain names.
/// </summary>
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<string>(
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<string> 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}";
}
}
6 changes: 6 additions & 0 deletions src/AR.Iec61850/Scl/Export/LiveIedSclExportModels.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
/// <summary>
/// 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.
/// </summary>
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;
Expand Down
134 changes: 134 additions & 0 deletions tests/AR.Iec61850.Tests/Scl/LiveIedSclIedIdentityTests.cs
Original file line number Diff line number Diff line change
@@ -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<XElement>(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<XElement>(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"
}
]
};
}
Loading