diff --git a/src/AR.Iec61850/Discovery/TrustedSclIedIdentityMatcher.cs b/src/AR.Iec61850/Discovery/TrustedSclIedIdentityMatcher.cs
new file mode 100644
index 00000000..a3b8918a
--- /dev/null
+++ b/src/AR.Iec61850/Discovery/TrustedSclIedIdentityMatcher.cs
@@ -0,0 +1,112 @@
+using System.Net;
+using System.Xml.Linq;
+
+namespace AR.Iec61850.Discovery;
+
+///
+/// Evidence-only IEC 61850 identity reconciliation. A server exposes MMS domains,
+/// not a guaranteed physical IED name or LDevice.inst boundary. Only a unique,
+/// fully matched engineering SCL topology can promote a provisional identity.
+/// No vendor-name heuristic, network call, cached-IP assumption, or model mutation.
+///
+public static class TrustedSclIedIdentityMatcher
+{
+ private static readonly XNamespace Scl = "http://www.iec.ch/61850/2003/SCL";
+
+ public static LiveIedIdentity? TryMatch(
+ XDocument trustedScl,
+ IEnumerable observedMmsDomains,
+ string? connectedHost,
+ string? selectedIedName = null)
+ {
+ ArgumentNullException.ThrowIfNull(trustedScl);
+ ArgumentNullException.ThrowIfNull(observedMmsDomains);
+ if (trustedScl.Root?.Name != Scl + "SCL") return null;
+ var observed = observedMmsDomains
+ .Where(s => !string.IsNullOrWhiteSpace(s))
+ .Select(s => s.Trim())
+ .Distinct(StringComparer.OrdinalIgnoreCase)
+ .ToHashSet(StringComparer.OrdinalIgnoreCase);
+ if (observed.Count == 0) return null;
+
+ var matches = new List<(string Ied, string Ap, Dictionary Aliases, string? Ip)>();
+ foreach (var ied in trustedScl.Root.Elements(Scl + "IED"))
+ {
+ var name = ((string?)ied.Attribute("name") ?? "").Trim();
+ if (string.IsNullOrWhiteSpace(name) ||
+ (!string.IsNullOrWhiteSpace(selectedIedName) &&
+ !name.Equals(selectedIedName.Trim(), StringComparison.OrdinalIgnoreCase)))
+ continue;
+
+ foreach (var ap in ied.Elements(Scl + "AccessPoint"))
+ {
+ var apName = ((string?)ap.Attribute("name") ?? "").Trim();
+ if (string.IsNullOrWhiteSpace(apName)) continue;
+ var devices = ap.Element(Scl + "Server")?.Elements(Scl + "LDevice").ToArray()
+ ?? Array.Empty();
+ if (devices.Length != observed.Count) continue;
+
+ var aliases = new Dictionary(StringComparer.OrdinalIgnoreCase);
+ var invalid = false;
+ foreach (var ld in devices)
+ {
+ var inst = ((string?)ld.Attribute("inst") ?? "").Trim();
+ var ldName = ((string?)ld.Attribute("ldName") ?? "").Trim();
+ if (string.IsNullOrWhiteSpace(inst) || !IsValidSclIdentifier(inst))
+ {
+ invalid = true;
+ break;
+ }
+ var domain = ldName.Length > 0 ? ldName : name + inst;
+ if (!observed.Contains(domain) || !aliases.TryAdd(domain,inst))
+ {
+ invalid = true;
+ break;
+ }
+ }
+ if (invalid || aliases.Count != observed.Count) continue;
+
+ var connected = trustedScl.Root.Element(Scl + "Communication")?
+ .Descendants(Scl + "ConnectedAP")
+ .Where(x => string.Equals((string?)x.Attribute("iedName"),name,StringComparison.OrdinalIgnoreCase)
+ && string.Equals((string?)x.Attribute("apName"),apName,StringComparison.OrdinalIgnoreCase))
+ .ToArray() ?? Array.Empty();
+ if (connected.Length > 1) continue;
+ var ipValues = connected.SingleOrDefault()?
+ .Element(Scl + "Address")?.Elements(Scl + "P")
+ .Where(x => string.Equals((string?)x.Attribute("type"),"IP",StringComparison.OrdinalIgnoreCase))
+ .Select(x => x.Value.Trim()).Take(2).ToArray() ?? Array.Empty();
+ if (ipValues.Length > 1) continue;
+ var ip = ipValues.SingleOrDefault();
+ if (!string.IsNullOrWhiteSpace(ip) && !string.IsNullOrWhiteSpace(connectedHost))
+ {
+ if (!IPAddress.TryParse(ip,out var designIp) ||
+ !IPAddress.TryParse(connectedHost.Trim(),out var observedIp) ||
+ !designIp.Equals(observedIp))
+ continue; // never promote a model for a different endpoint
+ }
+ matches.Add((name, apName, aliases, ip));
+ }
+ }
+ // A single IED can have multiple APs and the same LD topology. This
+ // remains ambiguous until AP is independently selected/proven.
+ if (matches.Count != 1) return null;
+ var match=matches[0];
+ return new LiveIedIdentity
+ {
+ IedName=match.Ied,
+ Source="TrustedSclExactDomainMatch",
+ Confidence=LiveIedDiscoveryConfidenceLevel.High,
+ IsAmbiguous=false,
+ CandidateNames=[match.Ied],
+ LogicalDeviceAliases=match.Aliases,
+ Evidence=[
+ $"Trusted SCL IED '{match.Ied}' / AccessPoint '{match.Ap}' matches all {observed.Count} observed MMS domains and unique LDevice instances.",
+ string.IsNullOrWhiteSpace(match.Ip) ? "No endpoint IP asserted by SCL." : $"SCL endpoint IP {match.Ip}."
+ ]
+ };
+ }
+
+ private static bool IsValidSclIdentifier(string s)
+ => s.All(c => char.IsLetterOrDigit(c) || c is '_' or '-' or '.');
+}
diff --git a/src/AR.Iec61850/Scl/Export/AuthoritativeLiveIedSclExporter.cs b/src/AR.Iec61850/Scl/Export/AuthoritativeLiveIedSclExporter.cs
index ccd70779..b92de8a7 100644
--- a/src/AR.Iec61850/Scl/Export/AuthoritativeLiveIedSclExporter.cs
+++ b/src/AR.Iec61850/Scl/Export/AuthoritativeLiveIedSclExporter.cs
@@ -26,7 +26,14 @@ public static LiveIedSclExportResult WriteFiles(
try
{
var document = XDocument.Load(result.SclPath, LoadOptions.PreserveWhitespace);
- if (!string.IsNullOrWhiteSpace(options.IedNameOverride))
+ if (options.VerifiedIdentity is { } verified)
+ {
+ if (!string.IsNullOrWhiteSpace(options.IedNameOverride) &&
+ !verified.IedName.Equals(options.IedNameOverride.Trim(), StringComparison.Ordinal))
+ throw new InvalidDataException("Caller IED override conflicts with trusted SCL evidence.");
+ document = ApplyVerifiedIdentity(document, model, verified);
+ }
+ else if (!string.IsNullOrWhiteSpace(options.IedNameOverride))
document = ApplyIdentity(document, model, options.IedNameOverride);
document = ApplyReportControlConfiguration(document, model, options.ResolvedSchemaProfile);
@@ -99,6 +106,80 @@ public static XDocument ApplyIdentity(
return document;
}
+
+ ///
+ /// Normalizes both IED identity and LD instance mappings from verified SCL
+ /// topology. Physical MMS domain / FCDA evidence remains unchanged.
+ ///
+ public static XDocument ApplyVerifiedIdentity(
+ XDocument generated,
+ LiveIedModelDiscoveryDocument model,
+ LiveIedIdentity identity)
+ {
+ ArgumentNullException.ThrowIfNull(identity);
+ var observed = model.LogicalDevices
+ .Select(LogicalDeviceDomain)
+ .Where(x => !string.IsNullOrWhiteSpace(x))
+ .Distinct(StringComparer.OrdinalIgnoreCase)
+ .ToHashSet(StringComparer.OrdinalIgnoreCase);
+ if (!identity.Source.Equals("TrustedSclExactDomainMatch", StringComparison.Ordinal) ||
+ identity.Confidence != LiveIedDiscoveryConfidenceLevel.High ||
+ identity.IsAmbiguous ||
+ observed.Count == 0 ||
+ identity.LogicalDeviceAliases.Count != observed.Count ||
+ identity.LogicalDeviceAliases.Keys.Any(x => !observed.Contains(x)) ||
+ identity.LogicalDeviceAliases.Values.Any(x => string.IsNullOrWhiteSpace(x)) ||
+ identity.LogicalDeviceAliases.Values.Distinct(StringComparer.OrdinalIgnoreCase).Count() != observed.Count)
+ throw new InvalidDataException("SCL identity evidence is not a unique complete MMS-domain mapping.");
+
+ // Existing authoritative normalization proves identity and preserves the
+ // MMS wire-domain graph. Transform LDevice.inst and FCDA LD references
+ // together so that a single-domain BCUGEF650 becomes BCUGE/F650.
+ var document = ApplyIdentity(generated, model, identity.IedName);
+ var rewrites = new Dictionary(StringComparer.OrdinalIgnoreCase);
+ var seen = new HashSet(StringComparer.OrdinalIgnoreCase);
+ var ied = document.Root?.Element(Scl + "IED")
+ ?? throw new InvalidDataException("SCL has no unique IED.");
+ var nodes = ied.Descendants(Scl + "LDevice").ToArray();
+ if (nodes.Length != observed.Count)
+ throw new InvalidDataException("Generated LD set and observed MMS domain set differ.");
+
+ foreach (var ld in nodes)
+ {
+ var oldInst = ((string?)ld.Attribute("inst") ?? "").Trim();
+ var explicitDomain = ((string?)ld.Attribute("ldName") ?? "").Trim();
+ var domain = explicitDomain.Length != 0 ? explicitDomain : identity.IedName + oldInst;
+ if (!identity.LogicalDeviceAliases.TryGetValue(domain, out var newInst) ||
+ !seen.Add(domain))
+ throw new InvalidDataException($"Generated SCL domain '{domain}' is not uniquely matched.");
+ rewrites.Add(oldInst, newInst);
+ ld.SetAttributeValue("inst", newInst);
+ ld.SetAttributeValue("ldName",
+ domain.Equals(identity.IedName + newInst, StringComparison.OrdinalIgnoreCase)
+ ? null : domain);
+ }
+ if (seen.Count != observed.Count)
+ throw new InvalidDataException("Generated SCL did not map every observed MMS domain.");
+
+ // FCDA ldInst references refer to LDevice.inst, not the MMS domain.
+ // Never rewrite references belonging to an explicitly different IED.
+ foreach (var fcda in ied.Descendants(Scl + "FCDA"))
+ {
+ var fcdaIed = ((string?)fcda.Attribute("iedName") ?? "").Trim();
+ if (fcdaIed.Length > 0 &&
+ !fcdaIed.Equals(model.IedName, StringComparison.OrdinalIgnoreCase) &&
+ !fcdaIed.Equals(identity.IedName, StringComparison.OrdinalIgnoreCase))
+ continue;
+ var oldRef = ((string?)fcda.Attribute("ldInst") ?? "").Trim();
+ if (rewrites.TryGetValue(oldRef, out var mapped))
+ fcda.SetAttributeValue("ldInst", mapped);
+ if (fcdaIed.Equals(model.IedName, StringComparison.OrdinalIgnoreCase))
+ fcda.SetAttributeValue("iedName", identity.IedName);
+ }
+ ValidateIdentity(document, identity.IedName, model);
+ return document;
+ }
+
public static XDocument ApplyReportControlConfiguration(
XDocument source,
LiveIedModelDiscoveryDocument model,
diff --git a/src/AR.Iec61850/Scl/Export/CanonicalLiveIedSclExporter.cs b/src/AR.Iec61850/Scl/Export/CanonicalLiveIedSclExporter.cs
index 925cbe11..ae31f67b 100644
--- a/src/AR.Iec61850/Scl/Export/CanonicalLiveIedSclExporter.cs
+++ b/src/AR.Iec61850/Scl/Export/CanonicalLiveIedSclExporter.cs
@@ -19,7 +19,8 @@ public static LiveIedSclExportResult WriteFiles(
LiveIedCanonicalModel canonical,
string sclPath,
SclSchemaProfile schemaProfile = SclSchemaProfile.Edition2V31,
- string profile = "safe-connection")
+ string profile = "safe-connection",
+ LiveIedIdentity? verifiedIdentity = null)
{
ArgumentNullException.ThrowIfNull(canonical);
ValidateCanonicalCommunication(canonical);
@@ -41,7 +42,8 @@ public static LiveIedSclExportResult WriteFiles(
? "StationBus"
: communication.SubNetworkName.Trim(),
IpAddress = communication.Host.Trim(),
- IedNameOverride = canonical.IedName,
+ IedNameOverride = verifiedIdentity?.IedName ?? canonical.IedName,
+ VerifiedIdentity = verifiedIdentity,
IpSubnet = communication.IpSubnet?.Trim() ?? string.Empty,
IpGateway = communication.IpGateway?.Trim() ?? string.Empty,
OsiApTitle = association.ApTitle.Trim(),
@@ -53,10 +55,10 @@ public static LiveIedSclExportResult WriteFiles(
});
var document = XDocument.Load(result.SclPath, LoadOptions.PreserveWhitespace);
- ApplyCanonicalCommunication(document, canonical);
+ ApplyCanonicalCommunication(document, canonical, verifiedIdentity?.IedName);
PreserveRuntimeServiceCapacity(document, canonical.Discovery);
- ApplyCanonicalInstanceValues(document, canonical);
- ValidateRoundTripAssociation(document, canonical);
+ ApplyCanonicalInstanceValues(document, canonical, verifiedIdentity);
+ ValidateRoundTripAssociation(document, canonical, verifiedIdentity?.IedName);
document.Save(result.SclPath);
return result;
}
@@ -106,11 +108,12 @@ public static void ValidateCanonicalCommunication(LiveIedCanonicalModel canonica
throw new InvalidDataException(string.Join(" | ", errors));
}
- private static void ApplyCanonicalCommunication(XDocument document, LiveIedCanonicalModel canonical)
+ private static void ApplyCanonicalCommunication(
+ XDocument document, LiveIedCanonicalModel canonical, string? trustedIedName)
{
var connectedAp = document.Descendants(Scl + "ConnectedAP").SingleOrDefault()
?? throw new InvalidDataException("Generated SCL must contain exactly one ConnectedAP.");
- connectedAp.SetAttributeValue("iedName", canonical.IedName);
+ connectedAp.SetAttributeValue("iedName", trustedIedName ?? canonical.IedName);
connectedAp.SetAttributeValue("apName", canonical.AccessPointName);
var subNetwork = connectedAp.Parent;
@@ -163,7 +166,8 @@ public static void PreserveRuntimeServiceCapacity(
public static void ApplyCanonicalInstanceValues(
XDocument document,
- LiveIedCanonicalModel canonical)
+ LiveIedCanonicalModel canonical,
+ LiveIedIdentity? verifiedIdentity = null)
{
ArgumentNullException.ThrowIfNull(document);
ArgumentNullException.ThrowIfNull(canonical);
@@ -212,7 +216,7 @@ public static void ApplyCanonicalInstanceValues(
if (logicalNodeModel is null)
continue;
- var lDevice = FindExportedLogicalDevice(document, canonical, evidence.Domain);
+ var lDevice = FindExportedLogicalDevice(document, canonical, evidence.Domain, verifiedIdentity);
if (lDevice is null)
continue;
@@ -272,9 +276,20 @@ public static void ApplyCanonicalInstanceValues(
private static XElement? FindExportedLogicalDevice(
XDocument document,
LiveIedCanonicalModel canonical,
- string domain)
+ string domain,
+ LiveIedIdentity? verifiedIdentity)
{
var normalizedDomain = domain.Trim();
+ // A verified engineering source provides the exact LD instance.
+ // Avoid recomputing it from the canonical *provisional* IED name.
+ if (verifiedIdentity is not null &&
+ verifiedIdentity.LogicalDeviceAliases.TryGetValue(normalizedDomain,out var trustedInst))
+ {
+ var trusted = document.Descendants(Scl + "LDevice")
+ .Where(ld => string.Equals((string?)ld.Attribute("inst"),trustedInst,StringComparison.OrdinalIgnoreCase))
+ .ToArray();
+ return trusted.Length == 1 ? trusted[0] : null;
+ }
var iedName = canonical.IedName.Trim();
var stripped = normalizedDomain.StartsWith(iedName, StringComparison.OrdinalIgnoreCase) &&
normalizedDomain.Length > iedName.Length
@@ -462,12 +477,13 @@ private static bool TryFormatScalarValue(
}
}
- private static void ValidateRoundTripAssociation(XDocument document, LiveIedCanonicalModel canonical)
+ private static void ValidateRoundTripAssociation(
+ XDocument document, LiveIedCanonicalModel canonical, string? trustedIedName)
{
var profiles = SclMmsAssociationProfileReader.Read(document);
- var remote = profiles.Find(canonical.IedName, canonical.AccessPointName)
+ var remote = profiles.Find(trustedIedName ?? canonical.IedName, canonical.AccessPointName)
?? throw new InvalidDataException(
- $"Generated SCL cannot round-trip its own ConnectedAP '{canonical.IedName}/{canonical.AccessPointName}'.");
+ $"Generated SCL cannot round-trip its own ConnectedAP '{trustedIedName ?? canonical.IedName}/{canonical.AccessPointName}'.");
var canonicalAssociation = canonical.Communication.Association;
var roundTripErrors = new List();
diff --git a/src/AR.Iec61850/Scl/Export/LiveIedSclExportModels.cs b/src/AR.Iec61850/Scl/Export/LiveIedSclExportModels.cs
index c6b6ac3f..ad2c7838 100644
--- a/src/AR.Iec61850/Scl/Export/LiveIedSclExportModels.cs
+++ b/src/AR.Iec61850/Scl/Export/LiveIedSclExportModels.cs
@@ -15,6 +15,13 @@ public sealed class LiveIedSclExportOptions
/// when an implementation uses explicit communication-level LD names.
///
public string IedNameOverride { get; init; } = string.Empty;
+
+ ///
+ /// Optional evidence proved against the exact observed MMS domain set by
+ /// TrustedSclIedIdentityMatcher. Not a display alias or an IP-based guess.
+ /// Export fails closed on incomplete/colliding aliases.
+ ///
+ public AR.Iec61850.Discovery.LiveIedIdentity? VerifiedIdentity { get; init; }
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/Discovery/TrustedSclIedIdentityMatcherTests.cs b/tests/AR.Iec61850.Tests/Discovery/TrustedSclIedIdentityMatcherTests.cs
new file mode 100644
index 00000000..62274a78
--- /dev/null
+++ b/tests/AR.Iec61850.Tests/Discovery/TrustedSclIedIdentityMatcherTests.cs
@@ -0,0 +1,172 @@
+using AR.Iec61850.Discovery;
+using AR.Iec61850.Scl.Export;
+using System.Xml.Linq;
+
+namespace AR.Iec61850.Tests.Discovery;
+
+public sealed class TrustedSclIedIdentityMatcherTests
+{
+ private const string SclNs = "http://www.iec.ch/61850/2003/SCL";
+
+ [Fact]
+ public void GE_F650_Single_Domain_Uses_SCL_Evidence_Not_Vendor_Suffix_Heuristic()
+ {
+ var inferred = LiveIedIdentityResolver.Resolve(["BCUGEF650"], "192.16.1.33");
+ Assert.Equal("HostFallback", inferred.Source);
+ Assert.Equal(LiveIedDiscoveryConfidenceLevel.Low,inferred.Confidence);
+
+ var identity = TrustedSclIedIdentityMatcher.TryMatch(
+ Design("BCUGE", "F650", "192.16.1.33"), ["BCUGEF650"], "192.16.1.33");
+ Assert.NotNull(identity);
+ Assert.Equal("BCUGE",identity.IedName);
+ Assert.Equal("F650",identity.LogicalDeviceAliases["BCUGEF650"]);
+ Assert.Equal("TrustedSclExactDomainMatch",identity.Source);
+ Assert.Equal(LiveIedDiscoveryConfidenceLevel.High,identity.Confidence);
+ }
+
+ [Fact]
+ public void Verified_F650_Export_RoundTrips_Mms_Domain_And_Fcda_LdInst()
+ {
+ var model = Model("IED_192_16_1_33","BCUGEF650");
+ var identity = TrustedSclIedIdentityMatcher.TryMatch(
+ Design("BCUGE","F650","192.16.1.33"),["BCUGEF650"],"192.16.1.33");
+ Assert.NotNull(identity);
+
+ var generated = LiveIedSclExporter.BuildDocument(model,
+ new LiveIedSclExportOptions { SchemaProfile = SclSchemaProfile.Edition1V16 });
+ var normalized = AuthoritativeLiveIedSclExporter.ApplyVerifiedIdentity(generated,model,identity);
+ XNamespace ns=SclNs;
+ var ied=Assert.Single(normalized.Root!.Elements(ns+"IED"));
+ Assert.Equal("BCUGE",(string?)ied.Attribute("name"));
+ var device=Assert.Single(ied.Descendants(ns+"LDevice"));
+ Assert.Equal("F650",(string?)device.Attribute("inst"));
+ Assert.Null(device.Attribute("ldName")); // implicit MMS domain BCUGEF650
+ Assert.Equal("BCUGE",(string?)Assert.Single(normalized.Descendants(ns+"ConnectedAP")).Attribute("iedName"));
+ var fcda=Assert.Single(normalized.Descendants(ns+"FCDA"));
+ Assert.Equal("F650",(string?)fcda.Attribute("ldInst"));
+ }
+
+ [Fact]
+ public void Explicit_LdName_Remains_Exact_When_Different_From_Implicit_Mms_Domain()
+ {
+ var scl=Design("BCUGE","F650","192.16.1.33","CUSTOM_WIRE_DOMAIN");
+ var identity=TrustedSclIedIdentityMatcher.TryMatch(scl,
+ ["CUSTOM_WIRE_DOMAIN"],"192.16.1.33");
+ Assert.NotNull(identity);
+ var model=Model("IED_192_16_1_33","CUSTOM_WIRE_DOMAIN");
+ var doc=AuthoritativeLiveIedSclExporter.ApplyVerifiedIdentity(
+ LiveIedSclExporter.BuildDocument(model,new LiveIedSclExportOptions()),model,identity);
+ XNamespace ns=SclNs;
+ var ld=Assert.Single(doc.Descendants(ns+"LDevice"));
+ Assert.Equal("F650",(string?)ld.Attribute("inst"));
+ Assert.Equal("CUSTOM_WIRE_DOMAIN",(string?)ld.Attribute("ldName"));
+ Assert.Equal("F650",(string?)Assert.Single(doc.Descendants(ns+"FCDA")).Attribute("ldInst"));
+ }
+
+ [Fact]
+ public void Different_Host_Or_Missing_Domain_Or_Wrong_Selected_IED_Fails_Closed()
+ {
+ var scl=Design("BCUGE","F650","192.16.1.33");
+ Assert.Null(TrustedSclIedIdentityMatcher.TryMatch(scl,["BCUGEF650"],"192.16.1.34"));
+ Assert.Null(TrustedSclIedIdentityMatcher.TryMatch(scl,["BCUGEF650","BCUGECTRL"],"192.16.1.33"));
+ Assert.Null(TrustedSclIedIdentityMatcher.TryMatch(scl,["BCUGEF650"],"192.16.1.33","OTHER_IED"));
+ Assert.Null(TrustedSclIedIdentityMatcher.TryMatch(scl,["DIFFERENT"],"192.16.1.33"));
+ }
+
+ [Fact]
+ public void Ambiguous_Duplicate_AccessPoints_And_Conflicting_Sources_Are_Not_Promoted()
+ {
+ XNamespace ns=SclNs;
+ var scl=Design("BCUGE","F650","192.16.1.33");
+ var ied=Assert.Single(scl.Descendants(ns+"IED"));
+ var ap=Assert.Single(ied.Elements(ns+"AccessPoint"));
+ var duplicate=new XElement(ap);
+ duplicate.SetAttributeValue("name","S2");
+ ied.Add(duplicate);
+ Assert.Null(TrustedSclIedIdentityMatcher.TryMatch(scl,["BCUGEF650"],"192.16.1.33"));
+
+ var model=Model("IED_192_16_1_33","BCUGEF650");
+ var unverified=LiveIedIdentityResolver.Resolve(["BCUGEF650"],"192.16.1.33");
+ Assert.Throws(() =>
+ AuthoritativeLiveIedSclExporter.ApplyVerifiedIdentity(
+ LiveIedSclExporter.BuildDocument(model,new LiveIedSclExportOptions()),
+ model,unverified));
+ }
+
+ [Fact]
+ public void Other_Devices_And_Already_Recognized_Multiple_Domains_Are_Unchanged()
+ {
+ var old=LiveIedIdentityResolver.Resolve(
+ ["OLSF501LD0","OLSF501CTRL"],"192.0.2.10");
+ Assert.Equal("OLSF501",old.IedName);
+ var other=LiveIedIdentityResolver.Resolve(
+ ["OCR7SJ8Application","OCR7SJ8CB1","OCR7SJ8Mod2_MU1"],
+ "192.0.2.12");
+ Assert.Equal("OCR7SJ8",other.IedName);
+ }
+
+ private static XDocument Design(string ied,string inst,string ip,string? ldName=null)
+ {
+ XNamespace ns=SclNs;
+ return new XDocument(new XElement(ns+"SCL",
+ new XElement(ns+"Communication",
+ new XElement(ns+"SubNetwork",
+ new XElement(ns+"ConnectedAP",
+ new XAttribute("iedName",ied),new XAttribute("apName","S1"),
+ new XElement(ns+"Address",
+ new XElement(ns+"P",new XAttribute("type","IP"),ip))))),
+ new XElement(ns+"IED",new XAttribute("name",ied),
+ new XElement(ns+"AccessPoint",new XAttribute("name","S1"),
+ new XElement(ns+"Server",
+ new XElement(ns+"LDevice",
+ new XAttribute("inst",inst),
+ ldName is null ? null : new XAttribute("ldName",ldName),
+ new XElement(ns+"LN0",
+ new XAttribute("lnType","LLN0_type"))))))));
+ }
+
+ private static LiveIedModelDiscoveryDocument Model(string ied,string domain)
+ => new()
+ {
+ Host="192.16.1.33",
+ IedName=ied,
+ AccessPointName="S1",
+ LogicalDevices=[
+ new LiveIedLogicalDeviceModel
+ {
+ MmsDomain=domain,
+ Inst=domain,
+ LogicalNodes=[
+ new LiveIedLogicalNodeModel
+ {
+ Name="LLN0",
+ LnClass="LLN0",
+ ProposedLnTypeId="LN_LLN0_type"
+ },
+ new LiveIedLogicalNodeModel
+ {
+ Name="CSWI6",
+ LnClass="CSWI",
+ LnInst="6",
+ ProposedLnTypeId="LN_CSWI6_type"
+ }
+ ]
+ }
+ ],
+ DataSets=[
+ new LiveIedDataSetModel
+ {
+ Reference=$"{domain}/LLN0.Digital",
+ Domain=domain,LogicalNode="LLN0",Name="Digital",
+ MemberCount=1,
+ Members=[
+ new LiveIedDataSetMemberModel
+ {
+ Index=0,Reference=$"{domain}/CSWI6.Pos.stVal",
+ FunctionalConstraint="ST"
+ }
+ ]
+ }
+ ]
+ };
+}
diff --git a/tests/AR.Iec61850.Tests/Engineering/CanonicalLiveIedSclExporterTests.cs b/tests/AR.Iec61850.Tests/Engineering/CanonicalLiveIedSclExporterTests.cs
index c6d7ed27..1010e7d7 100644
--- a/tests/AR.Iec61850.Tests/Engineering/CanonicalLiveIedSclExporterTests.cs
+++ b/tests/AR.Iec61850.Tests/Engineering/CanonicalLiveIedSclExporterTests.cs
@@ -10,6 +10,69 @@ public sealed class CanonicalLiveIedSclExporterTests
{
private static readonly XNamespace Scl = "http://www.iec.ch/61850/2003/SCL";
+ [Fact]
+ public void VerifiedSclIdentity_KeepsAcceptedMmsAssociationWhileMappingLogicalDevice()
+ {
+ var accepted = CreateCanonical();
+ const string observedDomain = "IEDLD0";
+ var canonical = new LiveIedCanonicalModel
+ {
+ Discovery = new LiveIedModelDiscoveryDocument
+ {
+ Host = "10.20.30.40", IedName = "IED", AccessPointName = "AP1",
+ LogicalDevices = [
+ new LiveIedLogicalDeviceModel
+ {
+ MmsDomain = observedDomain, Inst = observedDomain,
+ LogicalNodes = [
+ new LiveIedLogicalNodeModel
+ {
+ Name = "LLN0", LnClass = "LLN0",
+ ProposedLnTypeId = "LN_LLN0"
+ }
+ ]
+ }
+ ]
+ },
+ Communication = accepted.Communication
+ };
+ XNamespace ns = Scl;
+ var trusted = new XDocument(new XElement(ns+"SCL",
+ new XElement(ns+"Communication",new XElement(ns+"SubNetwork",
+ new XElement(ns+"ConnectedAP",
+ new XAttribute("iedName","BCUGE"),new XAttribute("apName","AP1"),
+ new XElement(ns+"Address",
+ new XElement(ns+"P",new XAttribute("type","IP"),"10.20.30.40"))))),
+ new XElement(ns+"IED",new XAttribute("name","BCUGE"),
+ new XElement(ns+"AccessPoint",new XAttribute("name","AP1"),
+ new XElement(ns+"Server",new XElement(ns+"LDevice",
+ new XAttribute("inst","F650"),
+ new XAttribute("ldName",observedDomain)))))));
+ var proof = TrustedSclIedIdentityMatcher.TryMatch(
+ trusted,[observedDomain],"10.20.30.40","BCUGE");
+ Assert.NotNull(proof);
+
+ var dir=Path.Combine(Path.GetTempPath(),"ariec-p10-canonical-"+Guid.NewGuid().ToString("N"));
+ Directory.CreateDirectory(dir);
+ try
+ {
+ var result=CanonicalLiveIedSclExporter.WriteFiles(canonical,
+ Path.Combine(dir,"BCUGE.iid"),SclSchemaProfile.Edition1V16,
+ verifiedIdentity:proof);
+ var doc=XDocument.Load(result.SclPath);
+ Assert.Equal("BCUGE",(string?)Assert.Single(doc.Descendants(ns+"IED")).Attribute("name"));
+ var ld=Assert.Single(doc.Descendants(ns+"LDevice"));
+ Assert.Equal("F650",(string?)ld.Attribute("inst"));
+ Assert.Equal(observedDomain,(string?)ld.Attribute("ldName"));
+ Assert.Equal("BCUGE",(string?)Assert.Single(doc.Descendants(ns+"ConnectedAP")).Attribute("iedName"));
+ Assert.NotNull(SclMmsAssociationProfileReader.Read(doc).Find("BCUGE",canonical.AccessPointName));
+ }
+ finally
+ {
+ Directory.Delete(dir,true);
+ }
+ }
+
[Fact]
public void CanonicalBuilder_Preserves_CaseDistinct_InitialFcLeafEvidence()
{