From 5d2766f6f30c3340b1f9a9cc3530ba8d73f4ef70 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 24 Sep 2026 09:08:10 +0700 Subject: [PATCH 1/3] fix(scl): require exact live ST integer type evidence for export-only correction --- Services/SclExportSemanticParityPatch.cs | 40 +++++++++++++++++++----- 1 file changed, 33 insertions(+), 7 deletions(-) diff --git a/Services/SclExportSemanticParityPatch.cs b/Services/SclExportSemanticParityPatch.cs index 1dc8623a3..46d90169e 100644 --- a/Services/SclExportSemanticParityPatch.cs +++ b/Services/SclExportSemanticParityPatch.cs @@ -30,24 +30,50 @@ public static SclExportSemanticParityPatchResult ApplyForLiveModel( 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 + // The object name narrows the compatibility scope; it is not sufficient + // evidence for a CDC/type correction on another relay or firmware. + var targetDataObjects = liveModel.LogicalDevices .SelectMany(device => device.LogicalNodes) .Where(node => string.Equals(node.Name, TargetLogicalNodeName, StringComparison.Ordinal)) .SelectMany(node => node.DataObjects) - .Any(dataObject => + .Where(dataObject => string.Equals(dataObject.Name, TargetDataObject, StringComparison.Ordinal) && dataObject.Reference.EndsWith( "/" + TargetLogicalNodeName + "." + TargetDataObject, - StringComparison.Ordinal)); + StringComparison.Ordinal)) + .ToArray(); + + if (targetDataObjects.Length == 0) + { + return new SclExportSemanticParityPatchResult( + 0, 0, Array.Empty()); + } + + if (targetDataObjects.Length != 1) + { + throw new InvalidDataException( + $"Ambiguous live {TargetLogicalNodeName}.{TargetDataObject}: {targetDataObjects.Length} matching objects."); + } + + var exactIntegerStatus = targetDataObjects[0].Attributes + .Where(attribute => + string.Equals(attribute.AttributePath, "stVal", StringComparison.Ordinal) && + string.Equals(attribute.FunctionalConstraint, "ST", StringComparison.Ordinal) && + string.Equals(attribute.TypeDiscoveryStatus, "Exact", StringComparison.Ordinal) && + attribute.TypeConfidence == LiveIedDiscoveryConfidenceLevel.Exact && + string.Equals(attribute.SclBType, "INT32", StringComparison.OrdinalIgnoreCase)) + .ToArray(); - if (!targetExistsInLiveModel) + if (exactIntegerStatus.Length != 1) { + // Preserve a legitimately Boolean or unproven status object. Never invent + // INS/INT32 from the name, old inferred CDC, or an instance Val string. return new SclExportSemanticParityPatchResult( 0, 0, - Array.Empty()); + [ + $"{TargetLogicalNodeName}.{TargetDataObject}: export-only correction skipped; exact ST stVal INT32 TypeSpecification evidence is unavailable or ambiguous." + ]); } var document = XDocument.Load(sclPath, LoadOptions.PreserveWhitespace); From e27fd321aafbf2db260ca2bbdd95052cb5320736 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 24 Sep 2026 09:08:20 +0700 Subject: [PATCH 2/3] fix(scl): surface skipped type correction as export diagnostic --- MainWindow.xaml.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/MainWindow.xaml.cs b/MainWindow.xaml.cs index ca8225c82..0a3ed54a4 100644 --- a/MainWindow.xaml.cs +++ b/MainWindow.xaml.cs @@ -1700,12 +1700,12 @@ private void SaveTypedModelAsScl( canonical, result); - if (semanticPatch.Changed) + if (semanticPatch.Messages.Count > 0) { AddLog( - "INFO", + semanticPatch.Changed ? "INFO" : "WARN", "SCL Export", - $"{device.Name}: export-only semantic parity applied after canonical serialization and before reload validation • " + + $"{device.Name}: export-only semantic parity {(semanticPatch.Changed ? "applied" : "skipped")} after canonical serialization and before reload validation • " + string.Join(" ", semanticPatch.Messages)); } } From 43893c60239b02b2da2de30ff5895ec258327e54 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 24 Sep 2026 09:08:40 +0700 Subject: [PATCH 3/3] test(scl): prove exact integer evidence gate and Boolean/unknown no-op --- .../SclExportSemanticParityPatchTests.cs | 58 ++++++++++++++++++- 1 file changed, 56 insertions(+), 2 deletions(-) diff --git a/tests/ARSAS.Tests/SclExportSemanticParityPatchTests.cs b/tests/ARSAS.Tests/SclExportSemanticParityPatchTests.cs index 35f55d202..ad3425090 100644 --- a/tests/ARSAS.Tests/SclExportSemanticParityPatchTests.cs +++ b/tests/ARSAS.Tests/SclExportSemanticParityPatchTests.cs @@ -118,6 +118,45 @@ public void PhysicalReferenceCounter_IsCorrectedOnlyInSavedScl_AndSharedTypeIsNo } } + [Theory] + [InlineData("BOOLEAN", "Exact", LiveIedDiscoveryConfidenceLevel.Exact)] + [InlineData("INT32", "NotRead", LiveIedDiscoveryConfidenceLevel.Low)] + [InlineData("INT32", "Exact", LiveIedDiscoveryConfidenceLevel.Low)] + [InlineData("INT32U", "Exact", LiveIedDiscoveryConfidenceLevel.Exact)] + public void MissingOrContradictoryLiveType_DoesNotInventInsInSavedScl( + string sclBType, + string discoveryStatus, + LiveIedDiscoveryConfidenceLevel confidence) + { + var root = Path.Combine( + Path.GetTempPath(), + "arsas-scl-exact-type-gate-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(root); + var path = Path.Combine(root, "relay.iid"); + + try + { + const string xml = ""; + File.WriteAllText(path, xml); + var model = PhysicalReferenceModel(sclBType, discoveryStatus, confidence); + var result = SclExportSemanticParityPatch.ApplyForLiveModel(model, path); + + Assert.False(result.Changed); + Assert.Equal(0, result.PatchedDataObjects); + Assert.Contains(result.Messages, message => + message.Contains("exact ST stVal INT32", StringComparison.Ordinal)); + Assert.Equal(xml, File.ReadAllText(path)); + Assert.Equal("SPS", Assert.Single(model.LogicalDevices + .SelectMany(device => device.LogicalNodes) + .SelectMany(node => node.DataObjects)).InferredCdc); + } + finally + { + if (Directory.Exists(root)) + Directory.Delete(root, recursive: true); + } + } + [Fact] public void NonTargetLiveModel_DoesNotRewriteSavedScl() { @@ -179,7 +218,10 @@ public void SaveHook_IsAfterCanonicalSerialization_AndBeforeReloadValidation() StringComparison.Ordinal); } - private static LiveIedModelDiscoveryDocument PhysicalReferenceModel() + private static LiveIedModelDiscoveryDocument PhysicalReferenceModel( + string sclBType = "INT32", + string discoveryStatus = "Exact", + LiveIedDiscoveryConfidenceLevel confidence = LiveIedDiscoveryConfidenceLevel.Exact) => new() { LogicalDevices = @@ -202,7 +244,19 @@ private static LiveIedModelDiscoveryDocument PhysicalReferenceModel() { Reference = "AA1E1F06R4ADD/MPLS_GGIO1.CBClsCounter", Name = "CBClsCounter", - InferredCdc = "SPS" + InferredCdc = "SPS", + Attributes = + [ + new LiveIedDataAttributeModel + { + ObjectReference = "AA1E1F06R4ADD/MPLS_GGIO1.CBClsCounter.stVal", + AttributePath = "stVal", + FunctionalConstraint = "ST", + SclBType = sclBType, + TypeDiscoveryStatus = discoveryStatus, + TypeConfidence = confidence + } + ] } ] }