From c1abd7a2cfe8f1ba11feed59bd8989f8402a07d0 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 20 Sep 2026 14:30:47 +0700 Subject: [PATCH 1/3] fix: isolate CB counter semantic parity to saved SCL --- Services/SclExportSemanticParityPatch.cs | 202 +++++++++++++++++++++++ 1 file changed, 202 insertions(+) create mode 100644 Services/SclExportSemanticParityPatch.cs diff --git a/Services/SclExportSemanticParityPatch.cs b/Services/SclExportSemanticParityPatch.cs new file mode 100644 index 000000000..1dc8623a3 --- /dev/null +++ b/Services/SclExportSemanticParityPatch.cs @@ -0,0 +1,202 @@ +using System.Globalization; +using System.Xml.Linq; +using AR.Iec61850.Discovery; + +namespace ArIED61850Tester.Services; + +public sealed record SclExportSemanticParityPatchResult( + int PatchedDataObjects, + int RemovedInvalidInstanceValues, + IReadOnlyList Messages) +{ + public bool Changed => PatchedDataObjects > 0 || RemovedInvalidInstanceValues > 0; +} + +public static class SclExportSemanticParityPatch +{ + private const string TargetLogicalNodeName = "MPLS_GGIO1"; + private const string TargetPrefix = "MPLS_"; + private const string TargetLnClass = "GGIO"; + private const string TargetLnInst = "1"; + private const string TargetDataObject = "CBClsCounter"; + + public static SclExportSemanticParityPatchResult ApplyForLiveModel( + LiveIedModelDiscoveryDocument liveModel, + string sclPath) + { + ArgumentNullException.ThrowIfNull(liveModel); + ArgumentException.ThrowIfNullOrWhiteSpace(sclPath); + + if (!File.Exists(sclPath)) + throw new FileNotFoundException("Generated SCL file does not exist.", sclPath); + + // This compatibility correction is deliberately keyed to the exact object + // proven by the physical comparison. It is not a generic name/value guess. + var targetExistsInLiveModel = liveModel.LogicalDevices + .SelectMany(device => device.LogicalNodes) + .Where(node => string.Equals(node.Name, TargetLogicalNodeName, StringComparison.Ordinal)) + .SelectMany(node => node.DataObjects) + .Any(dataObject => + string.Equals(dataObject.Name, TargetDataObject, StringComparison.Ordinal) && + dataObject.Reference.EndsWith( + "/" + TargetLogicalNodeName + "." + TargetDataObject, + StringComparison.Ordinal)); + + if (!targetExistsInLiveModel) + { + return new SclExportSemanticParityPatchResult( + 0, + 0, + Array.Empty()); + } + + var document = XDocument.Load(sclPath, LoadOptions.PreserveWhitespace); + var root = document.Root + ?? throw new InvalidDataException("Generated SCL has no root element."); + var ns = root.Name.Namespace; + + var targetLogicalNodes = document + .Descendants(ns + "LN") + .Where(IsTargetLogicalNode) + .ToArray(); + + if (targetLogicalNodes.Length != 1) + { + throw new InvalidDataException( + $"Expected exactly one {TargetLogicalNodeName} LN in generated SCL, found {targetLogicalNodes.Length}."); + } + + var logicalNode = targetLogicalNodes[0]; + var lnTypeId = Attribute(logicalNode, "lnType"); + if (string.IsNullOrWhiteSpace(lnTypeId)) + throw new InvalidDataException($"{TargetLogicalNodeName} has no lnType in generated SCL."); + + var lNodeType = document + .Descendants(ns + "LNodeType") + .SingleOrDefault(element => + string.Equals(Attribute(element, "id"), lnTypeId, StringComparison.Ordinal)) + ?? throw new InvalidDataException( + $"LNodeType '{lnTypeId}' for {TargetLogicalNodeName} was not found."); + + var dataObject = lNodeType + .Elements(ns + "DO") + .SingleOrDefault(element => + string.Equals(Attribute(element, "name"), TargetDataObject, StringComparison.Ordinal)) + ?? throw new InvalidDataException( + $"{TargetLogicalNodeName}.{TargetDataObject} was not found in LNodeType '{lnTypeId}'."); + + var doTypeId = Attribute(dataObject, "type"); + if (string.IsNullOrWhiteSpace(doTypeId)) + throw new InvalidDataException( + $"{TargetLogicalNodeName}.{TargetDataObject} has no DOType reference."); + + var originalDoType = document + .Descendants(ns + "DOType") + .SingleOrDefault(element => + string.Equals(Attribute(element, "id"), doTypeId, StringComparison.Ordinal)) + ?? throw new InvalidDataException($"DOType '{doTypeId}' was not found."); + + // If a future exporter interns templates, never let this compatibility repair + // mutate another object that happens to share the same DOType. + var referencesToDoType = document + .Descendants(ns + "DO") + .Count(element => + string.Equals(Attribute(element, "type"), doTypeId, StringComparison.Ordinal)); + + XElement targetDoType; + if (referencesToDoType > 1) + { + targetDoType = new XElement(originalDoType); + var uniqueId = UniqueTypeId(document, ns, doTypeId + "_ARSAS_INS"); + targetDoType.SetAttributeValue("id", uniqueId); + dataObject.SetAttributeValue("type", uniqueId); + originalDoType.AddAfterSelf(targetDoType); + } + else + { + targetDoType = originalDoType; + } + + var stVal = targetDoType + .Elements(ns + "DA") + .SingleOrDefault(element => + string.Equals(Attribute(element, "name"), "stVal", StringComparison.Ordinal)) + ?? throw new InvalidDataException( + $"{TargetLogicalNodeName}.{TargetDataObject} DOType has no root stVal DA."); + + var oldCdc = Attribute(targetDoType, "cdc"); + var oldBType = Attribute(stVal, "bType"); + + targetDoType.SetAttributeValue("cdc", "INS"); + stVal.SetAttributeValue("bType", "INT32"); + stVal.Attribute("type")?.Remove(); + + var removedInvalidValues = 0; + foreach (var doi in logicalNode + .Elements(ns + "DOI") + .Where(element => + string.Equals(Attribute(element, "name"), TargetDataObject, StringComparison.Ordinal))) + { + foreach (var dai in doi + .Elements(ns + "DAI") + .Where(element => + string.Equals(Attribute(element, "name"), "stVal", StringComparison.Ordinal))) + { + foreach (var value in dai.Elements(ns + "Val").ToArray()) + { + if (int.TryParse( + value.Value.Trim(), + NumberStyles.Integer, + CultureInfo.InvariantCulture, + out _)) + { + continue; + } + + value.Remove(); + removedInvalidValues++; + } + } + } + + document.Save(sclPath, SaveOptions.DisableFormatting); + + return new SclExportSemanticParityPatchResult( + 1, + removedInvalidValues, + [ + $"{TargetLogicalNodeName}.{TargetDataObject}: CDC {oldCdc} -> INS, stVal {oldBType} -> INT32." + ]); + } + + private static bool IsTargetLogicalNode(XElement element) + => string.Equals(Attribute(element, "prefix"), TargetPrefix, StringComparison.Ordinal) && + string.Equals(Attribute(element, "lnClass"), TargetLnClass, StringComparison.Ordinal) && + string.Equals(Attribute(element, "inst"), TargetLnInst, StringComparison.Ordinal); + + private static string UniqueTypeId(XDocument document, XNamespace ns, string candidate) + { + var used = document + .Descendants(ns + "DOType") + .Select(element => Attribute(element, "id")) + .Where(value => !string.IsNullOrWhiteSpace(value)) + .ToHashSet(StringComparer.Ordinal); + + if (!used.Contains(candidate)) + return candidate; + + for (var index = 2; ; index++) + { + var next = candidate + "_" + index.ToString(CultureInfo.InvariantCulture); + if (!used.Contains(next)) + return next; + } + } + + private static string Attribute(XElement element, string name) + => element.Attributes() + .FirstOrDefault(attribute => + string.Equals(attribute.Name.LocalName, name, StringComparison.Ordinal)) + ?.Value + ?? string.Empty; +} From 8cab2c055a4043660c786bfc9003b4b32308a311 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 20 Sep 2026 14:31:01 +0700 Subject: [PATCH 2/3] fix: apply SCL parity only after canonical serialization --- MainWindow.xaml.cs | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/MainWindow.xaml.cs b/MainWindow.xaml.cs index 4ee6894db..ca8225c82 100644 --- a/MainWindow.xaml.cs +++ b/MainWindow.xaml.cs @@ -1692,10 +1692,22 @@ private void SaveTypedModelAsScl( profile: "full-model"); try { + var semanticPatch = SclExportSemanticParityPatch.ApplyForLiveModel( + model, + result.SclPath); canonicalReloadWorkspace = CanonicalSclReloadValidator.Validate( _sclWorkspaceService, canonical, result); + + if (semanticPatch.Changed) + { + AddLog( + "INFO", + "SCL Export", + $"{device.Name}: export-only semantic parity applied after canonical serialization and before reload validation • " + + string.Join(" ", semanticPatch.Messages)); + } } catch { From f8306eca77c08a797a60db1219431c18ff84d2b4 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 20 Sep 2026 14:31:49 +0700 Subject: [PATCH 3/3] tests: lock export-only CB counter parity on 0d0b baseline --- .../SclExportSemanticParityPatchTests.cs | 228 ++++++++++++++++++ 1 file changed, 228 insertions(+) create mode 100644 tests/ARSAS.Tests/SclExportSemanticParityPatchTests.cs diff --git a/tests/ARSAS.Tests/SclExportSemanticParityPatchTests.cs b/tests/ARSAS.Tests/SclExportSemanticParityPatchTests.cs new file mode 100644 index 000000000..35f55d202 --- /dev/null +++ b/tests/ARSAS.Tests/SclExportSemanticParityPatchTests.cs @@ -0,0 +1,228 @@ +using System.Xml.Linq; +using AR.Iec61850.Discovery; +using ArIED61850Tester.Services; + +namespace ARSAS.Tests; + +public sealed class SclExportSemanticParityPatchTests +{ + [Fact] + public void PhysicalReferenceCounter_IsCorrectedOnlyInSavedScl_AndSharedTypeIsNotMutated() + { + var root = Path.Combine( + Path.GetTempPath(), + "arsas-scl-semantic-patch-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(root); + var path = Path.Combine(root, "relay.iid"); + + try + { + File.WriteAllText( + path, + """ + + + + + + + + + false + + + + + + + + + + + + + + + + + + + + + + """); + + var liveModel = PhysicalReferenceModel(); + var liveCounter = Assert.Single( + liveModel.LogicalDevices + .SelectMany(device => device.LogicalNodes) + .SelectMany(node => node.DataObjects)); + + Assert.Equal("SPS", liveCounter.InferredCdc); + + var result = SclExportSemanticParityPatch.ApplyForLiveModel( + liveModel, + path); + + Assert.True(result.Changed); + Assert.Equal(1, result.PatchedDataObjects); + Assert.Equal(1, result.RemovedInvalidInstanceValues); + + // The model bound to discovery/reporting is read-only input to the patch. + Assert.Equal("SPS", liveCounter.InferredCdc); + + var document = XDocument.Load(path); + var ns = document.Root!.Name.Namespace; + + var targetLn = document.Descendants(ns + "LN").Single( + element => + (string?)element.Attribute("prefix") == "MPLS_" && + (string?)element.Attribute("lnClass") == "GGIO" && + (string?)element.Attribute("inst") == "1"); + var targetLnTypeId = (string?)targetLn.Attribute("lnType"); + var targetLnType = document.Descendants(ns + "LNodeType").Single( + element => (string?)element.Attribute("id") == targetLnTypeId); + var targetDo = targetLnType.Elements(ns + "DO").Single( + element => (string?)element.Attribute("name") == "CBClsCounter"); + var targetDoTypeId = (string?)targetDo.Attribute("type"); + + Assert.NotEqual("DO_SHARED", targetDoTypeId); + + var targetDoType = document.Descendants(ns + "DOType").Single( + element => (string?)element.Attribute("id") == targetDoTypeId); + Assert.Equal("INS", (string?)targetDoType.Attribute("cdc")); + + var targetStVal = targetDoType.Elements(ns + "DA").Single( + element => (string?)element.Attribute("name") == "stVal"); + Assert.Equal("INT32", (string?)targetStVal.Attribute("bType")); + Assert.Null(targetStVal.Attribute("type")); + + var originalSharedType = document.Descendants(ns + "DOType").Single( + element => (string?)element.Attribute("id") == "DO_SHARED"); + Assert.Equal("SPS", (string?)originalSharedType.Attribute("cdc")); + Assert.Equal( + "BOOLEAN", + (string?)originalSharedType.Elements(ns + "DA").Single( + element => (string?)element.Attribute("name") == "stVal") + .Attribute("bType")); + + var instanceStVal = targetLn + .Elements(ns + "DOI").Single(element => (string?)element.Attribute("name") == "CBClsCounter") + .Elements(ns + "DAI").Single(element => (string?)element.Attribute("name") == "stVal"); + Assert.Empty(instanceStVal.Elements(ns + "Val")); + } + finally + { + if (Directory.Exists(root)) + Directory.Delete(root, recursive: true); + } + } + + [Fact] + public void NonTargetLiveModel_DoesNotRewriteSavedScl() + { + var root = Path.Combine( + Path.GetTempPath(), + "arsas-scl-semantic-noop-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(root); + var path = Path.Combine(root, "relay.iid"); + + try + { + const string xml = + ""; + File.WriteAllText(path, xml); + + var result = SclExportSemanticParityPatch.ApplyForLiveModel( + new LiveIedModelDiscoveryDocument(), + path); + + Assert.False(result.Changed); + Assert.Equal(xml, File.ReadAllText(path)); + } + finally + { + if (Directory.Exists(root)) + Directory.Delete(root, recursive: true); + } + } + + [Fact] + public void SaveHook_IsAfterCanonicalSerialization_AndBeforeReloadValidation() + { + var source = ReadRepoFile("MainWindow.xaml.cs"); + + var write = source.IndexOf( + "result = CanonicalLiveIedSclExporter.WriteFiles(", + StringComparison.Ordinal); + var patch = source.IndexOf( + "SclExportSemanticParityPatch.ApplyForLiveModel(", + write, + StringComparison.Ordinal); + var validate = source.IndexOf( + "canonicalReloadWorkspace = CanonicalSclReloadValidator.Validate(", + patch, + StringComparison.Ordinal); + + Assert.True(write >= 0); + Assert.True(patch > write); + Assert.True(validate > patch); + + var engineLock = ReadRepoFile("engines/ARIEC61850.lock.json"); + Assert.Contains( + "\"commit\": \"648124097621046f5f127ceb1cf853fea54db730\"", + engineLock, + StringComparison.Ordinal); + Assert.Contains( + "\"sourcePullRequest\": 135", + engineLock, + StringComparison.Ordinal); + } + + private static LiveIedModelDiscoveryDocument PhysicalReferenceModel() + => new() + { + LogicalDevices = + [ + new LiveIedLogicalDeviceModel + { + MmsDomain = "AA1E1F06R4ADD", + Inst = "ADD", + LogicalNodes = + [ + new LiveIedLogicalNodeModel + { + Name = "MPLS_GGIO1", + Prefix = "MPLS_", + LnClass = "GGIO", + LnInst = "1", + DataObjects = + [ + new LiveIedDataObjectModel + { + Reference = "AA1E1F06R4ADD/MPLS_GGIO1.CBClsCounter", + Name = "CBClsCounter", + InferredCdc = "SPS" + } + ] + } + ] + } + ] + }; + + private static string ReadRepoFile(string relativePath) + { + DirectoryInfo? directory = new(AppContext.BaseDirectory); + while (directory != null) + { + var candidate = Path.Combine(directory.FullName, relativePath); + if (File.Exists(candidate)) + return File.ReadAllText(candidate); + + directory = directory.Parent; + } + + throw new FileNotFoundException(relativePath); + } +}