From 14636107644a7eade9aaf116066572bc7c1dd65d Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 20 Sep 2026 11:24:23 +0700 Subject: [PATCH 1/7] discovery: prefer exact stVal type for status CDC --- .../Discovery/CdcInferenceEngine.cs | 100 ++++++++++++++++++ 1 file changed, 100 insertions(+) diff --git a/src/AR.Iec61850/Discovery/CdcInferenceEngine.cs b/src/AR.Iec61850/Discovery/CdcInferenceEngine.cs index f580a835..df14fa19 100644 --- a/src/AR.Iec61850/Discovery/CdcInferenceEngine.cs +++ b/src/AR.Iec61850/Discovery/CdcInferenceEngine.cs @@ -30,6 +30,43 @@ public static CdcInferenceResult Infer( string dataObjectName, IReadOnlyCollection attributePaths, IReadOnlyCollection functionalConstraints) + => InferCore( + logicalNodeClass, + dataObjectName, + attributePaths, + functionalConstraints, + Array.Empty()); + + /// + /// Type-authoritative overload used by live discovery when exact MMS + /// TypeSpecification evidence is available for DataAttributes. + /// + public static CdcInferenceResult Infer( + string logicalNodeClass, + string dataObjectName, + IReadOnlyCollection attributes) + { + ArgumentNullException.ThrowIfNull(attributes); + return InferCore( + logicalNodeClass, + dataObjectName, + attributes + .Select(attribute => attribute.AttributePath) + .Where(path => !string.IsNullOrWhiteSpace(path)) + .ToArray(), + attributes + .Select(attribute => attribute.FunctionalConstraint) + .Where(fc => !string.IsNullOrWhiteSpace(fc)) + .ToArray(), + attributes); + } + + private static CdcInferenceResult InferCore( + string logicalNodeClass, + string dataObjectName, + IReadOnlyCollection attributePaths, + IReadOnlyCollection functionalConstraints, + IReadOnlyCollection typedAttributes) { var evidence = new List(); var doName = dataObjectName.Trim(); @@ -93,6 +130,9 @@ public static CdcInferenceResult Infer( if (ContainsAny(attrs, "stVal") && ContainsAny(attrs, "q") && ContainsAny(attrs, "t")) { + if (TryInferTypedStatusCdc(typedAttributes, out var typedStatus)) + return typedStatus; + evidence.Add("contains stVal/q/t status triplet"); if (IsProtectionOperation(lnClass, doName)) return Result("ACT", 0.86, evidence.Append("protection LN operation/status DO pattern")); @@ -133,6 +173,66 @@ public static CdcInferenceResult Infer( return Result(string.Empty, 0.0, evidence.Append("CDC cannot be inferred from current online discovery only")); } + private static bool TryInferTypedStatusCdc( + IReadOnlyCollection attributes, + out CdcInferenceResult result) + { + result = new CdcInferenceResult(); + if (attributes.Count == 0) + return false; + + var stVal = attributes.FirstOrDefault(attribute => + string.Equals( + (attribute.AttributePath ?? string.Empty).Trim(), + "stVal", + StringComparison.OrdinalIgnoreCase)); + if (stVal is null || + stVal.TypeConfidence != LiveIedDiscoveryConfidenceLevel.Exact) + { + return false; + } + + var declaredType = FirstNonEmpty(stVal.SclBType, stVal.MmsType) + .Replace("_", string.Empty, StringComparison.Ordinal) + .Replace("-", string.Empty, StringComparison.Ordinal) + .Replace(" ", string.Empty, StringComparison.Ordinal) + .ToUpperInvariant(); + + if (declaredType is "BOOLEAN" or "BOOL") + { + result = Result( + "SPS", + 0.98, + new[] { "exact stVal type is BOOLEAN" }); + return true; + } + + if (declaredType.StartsWith("INT", StringComparison.Ordinal) || + declaredType.StartsWith("UINT", StringComparison.Ordinal) || + declaredType is "INTEGER" or "SIGNED" or "UNSIGNED") + { + result = Result( + "INS", + 0.98, + new[] { $"exact stVal type is {declaredType}" }); + return true; + } + + if (declaredType.StartsWith("ENUM", StringComparison.Ordinal)) + { + result = Result( + "ENS", + 0.98, + new[] { $"exact stVal type is {declaredType}" }); + return true; + } + + return false; + } + + private static string FirstNonEmpty(params string?[] values) + => values.FirstOrDefault(value => !string.IsNullOrWhiteSpace(value))?.Trim() ?? string.Empty; + private static bool IsProtectionOperation(string lnClass, string dataObjectName) => string.Equals(dataObjectName, "Op", StringComparison.OrdinalIgnoreCase) && ((lnClass.Length > 0 && lnClass[0] == 'P') || string.Equals(lnClass, "RREC", StringComparison.OrdinalIgnoreCase)); From 67e1434128cb531158525377c0462c2325781f7a Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 20 Sep 2026 11:24:33 +0700 Subject: [PATCH 2/7] discovery: feed exact attribute types into CDC inference --- src/AR.Iec61850/Discovery/LiveIedModelDiscoveryBuilder.cs | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/AR.Iec61850/Discovery/LiveIedModelDiscoveryBuilder.cs b/src/AR.Iec61850/Discovery/LiveIedModelDiscoveryBuilder.cs index 21025d14..b5545850 100644 --- a/src/AR.Iec61850/Discovery/LiveIedModelDiscoveryBuilder.cs +++ b/src/AR.Iec61850/Discovery/LiveIedModelDiscoveryBuilder.cs @@ -213,9 +213,7 @@ private static IEnumerable BuildDataObjects( .ThenBy(x => x.FunctionalConstraint, StringComparer.OrdinalIgnoreCase) .ThenBy(x => x.AttributePath, StringComparer.OrdinalIgnoreCase) .ToArray(); - var attrPaths = attributes.Select(x => x.AttributePath).Where(x => !string.IsNullOrWhiteSpace(x)).ToArray(); - var fcs = group.Select(x => x.FunctionalConstraint).Where(x => !string.IsNullOrWhiteSpace(x)).ToArray(); - var cdc = CdcInferenceEngine.Infer(parsedLn.SclLnClass, group.Key, attrPaths, fcs); + var cdc = CdcInferenceEngine.Infer(parsedLn.SclLnClass, group.Key, attributes); var reference = $"{ln.Domain}/{ln.Name}.{group.Key}"; yield return new LiveIedDataObjectModel { From e7d6a7e28b218cba6896bced573d7ebb05d2a45c Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 20 Sep 2026 11:24:49 +0700 Subject: [PATCH 3/7] tests: lock exact typed status CDC authority --- .../CdcInferenceTypeAuthorityTests.cs | 76 +++++++++++++++++++ 1 file changed, 76 insertions(+) create mode 100644 tests/AR.Iec61850.Tests/Discovery/CdcInferenceTypeAuthorityTests.cs diff --git a/tests/AR.Iec61850.Tests/Discovery/CdcInferenceTypeAuthorityTests.cs b/tests/AR.Iec61850.Tests/Discovery/CdcInferenceTypeAuthorityTests.cs new file mode 100644 index 00000000..7f6ae5ab --- /dev/null +++ b/tests/AR.Iec61850.Tests/Discovery/CdcInferenceTypeAuthorityTests.cs @@ -0,0 +1,76 @@ +using AR.Iec61850.Discovery; + +namespace AR.Iec61850.Tests.Discovery; + +public sealed class CdcInferenceTypeAuthorityTests +{ + [Fact] + public void ExactIntegerStatusType_UsesInsInsteadOfBooleanShapeGuess() + { + var attributes = StatusTriplet("INT32", "integer"); + + var result = CdcInferenceEngine.Infer("GGIO", "CounterStatus", attributes); + + Assert.Equal("INS", result.Cdc); + Assert.Equal(LiveIedDiscoveryConfidenceLevel.High, result.Level); + Assert.Contains(result.Evidence, item => + item.Contains("exact stVal type", StringComparison.OrdinalIgnoreCase)); + } + + [Fact] + public void ExactBooleanStatusType_UsesSps() + { + var attributes = StatusTriplet("BOOLEAN", "boolean"); + + var result = CdcInferenceEngine.Infer("GGIO", "BinaryStatus", attributes); + + Assert.Equal("SPS", result.Cdc); + Assert.Equal(LiveIedDiscoveryConfidenceLevel.High, result.Level); + } + + [Fact] + public void HeuristicIntegerLabel_DoesNotOverrideExistingConservativeStatusInference() + { + var attributes = StatusTriplet( + "INT32", + "integer", + LiveIedDiscoveryConfidenceLevel.Low); + + var result = CdcInferenceEngine.Infer("GGIO", "UnprovenStatus", attributes); + + Assert.Equal("SPS", result.Cdc); + } + + private static LiveIedDataAttributeModel[] StatusTriplet( + string sclBType, + string mmsType, + LiveIedDiscoveryConfidenceLevel confidence = LiveIedDiscoveryConfidenceLevel.Exact) + => + [ + new LiveIedDataAttributeModel + { + AttributePath = "stVal", + FunctionalConstraint = "ST", + SclBType = sclBType, + MmsType = mmsType, + TypeConfidence = confidence, + TypeDiscoveryStatus = confidence == LiveIedDiscoveryConfidenceLevel.Exact ? "Exact" : "NotRead" + }, + new LiveIedDataAttributeModel + { + AttributePath = "q", + FunctionalConstraint = "ST", + SclBType = "Quality", + MmsType = "bit-string", + TypeConfidence = confidence + }, + new LiveIedDataAttributeModel + { + AttributePath = "t", + FunctionalConstraint = "ST", + SclBType = "Timestamp", + MmsType = "utc-time", + TypeConfidence = confidence + } + ]; +} From 10f66e8781f6146696516ec97bcf2268f5a14ec0 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 20 Sep 2026 11:40:52 +0700 Subject: [PATCH 4/7] docs: keep semantic registry source-clean From cd3a6f2c18965eead41f45d04722ba02e35aeff2 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 20 Sep 2026 11:41:05 +0700 Subject: [PATCH 5/7] docs: remove external product wording from registry comments --- src/AR.Iec61850/Discovery/Iec61850StandardModelRegistry.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/AR.Iec61850/Discovery/Iec61850StandardModelRegistry.cs b/src/AR.Iec61850/Discovery/Iec61850StandardModelRegistry.cs index 904cd251..666e8535 100644 --- a/src/AR.Iec61850/Discovery/Iec61850StandardModelRegistry.cs +++ b/src/AR.Iec61850/Discovery/Iec61850StandardModelRegistry.cs @@ -51,13 +51,13 @@ public static class Iec61850StandardModelRegistry [Key("MMXU", "PF")] = Def("MMXU", "PF", "WYE", 0.84, "power factor"), // Harmonic measurements use phase-group CDCs. Physical AA1E1F06R4 - // plus IEDScout SCL prove that MHAI ThdA / ThdPhV are WYE objects whose + // plus independent interoperability SCL prove that MHAI ThdA / ThdPhV are WYE objects whose // phase children are CMV SDOs, not ACD indication structures. [Key("MHAI", "ThdA")] = Def("MHAI", "ThdA", "WYE", 0.98, "three-phase current THD"), [Key("MHAI", "ThdPhV")] = Def("MHAI", "ThdPhV", "WYE", 0.98, "three-phase voltage THD"), // Instrument-transformer semantics are standardized and were also observed in - // the AA1E1F06R4 IEDScout engineering model. Exact registry entries prevent + // the AA1E1F06R4 independent interoperability engineering model. Exact registry entries prevent // live discovery from dropping these configuration DOs when attribute-name // heuristics alone cannot infer their CDC safely. [Key("TCTR", "Mod")] = Def("TCTR", "Mod", "ENC", 0.98, "mode control"), From 9123c8aa1cc51a1e13c6750c0e1f8bee2a2de3b7 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 20 Sep 2026 11:43:40 +0700 Subject: [PATCH 6/7] tests: lock CBClsCounter integer CDC authority --- .../Discovery/CdcInferenceTypeAuthorityTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/AR.Iec61850.Tests/Discovery/CdcInferenceTypeAuthorityTests.cs b/tests/AR.Iec61850.Tests/Discovery/CdcInferenceTypeAuthorityTests.cs index 7f6ae5ab..bd75a3bf 100644 --- a/tests/AR.Iec61850.Tests/Discovery/CdcInferenceTypeAuthorityTests.cs +++ b/tests/AR.Iec61850.Tests/Discovery/CdcInferenceTypeAuthorityTests.cs @@ -9,7 +9,7 @@ public void ExactIntegerStatusType_UsesInsInsteadOfBooleanShapeGuess() { var attributes = StatusTriplet("INT32", "integer"); - var result = CdcInferenceEngine.Infer("GGIO", "CounterStatus", attributes); + var result = CdcInferenceEngine.Infer("GGIO", "CBClsCounter", attributes); Assert.Equal("INS", result.Cdc); Assert.Equal(LiveIedDiscoveryConfidenceLevel.High, result.Level); From 090d81944791be5b690d24342f9262495eda1a09 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 20 Sep 2026 13:00:42 +0700 Subject: [PATCH 7/7] fix: preserve exact live status semantics through SCL export --- .../Scl/Export/LiveIedSclExporter.cs | 52 ++++- .../Scl/LiveIedSclSemanticAuthorityTests.cs | 207 ++++++++++++++++++ 2 files changed, 258 insertions(+), 1 deletion(-) create mode 100644 tests/AR.Iec61850.Tests/Scl/LiveIedSclSemanticAuthorityTests.cs diff --git a/src/AR.Iec61850/Scl/Export/LiveIedSclExporter.cs b/src/AR.Iec61850/Scl/Export/LiveIedSclExporter.cs index ae583318..3e4534ba 100644 --- a/src/AR.Iec61850/Scl/Export/LiveIedSclExporter.cs +++ b/src/AR.Iec61850/Scl/Export/LiveIedSclExporter.cs @@ -742,7 +742,7 @@ private static bool IsEdition2ServiceTrackingCdc(string cdc) private static XElement BuildDoType(LiveIedDataObjectModel dataObject, string doTypeId, LiveIedSclBuildContext context, IReadOnlyCollection? exportAttributes = null, string logicalNodeClass = "") { - var cdc = dataObject.InferredCdc.Trim(); + var cdc = ResolveExportCdc(dataObject, logicalNodeClass, context); if (!CdcInferenceEngine.IsKnownCdc(cdc)) { cdc = "SPS"; @@ -803,6 +803,56 @@ private static XElement BuildDoType(LiveIedDataObjectModel dataObject, string do return doType; } + private static string ResolveExportCdc( + LiveIedDataObjectModel dataObject, + string logicalNodeClass, + LiveIedSclBuildContext context) + { + var declaredCdc = (dataObject.InferredCdc ?? string.Empty).Trim(); + + // Export is the last semantic boundary before the model becomes portable SCL. + // Exact live TypeSpecification evidence for stVal must not be downgraded by a + // stale conservative CDC that may have been inferred before typed evidence was + // available. Re-run the typed CDC inference only for exact LIVE stVal evidence; + // an opened SCL's explicit DOType@cdc remains its own engineering authority. + var exactLiveStVal = dataObject.Attributes.FirstOrDefault(attribute => + string.Equals( + (attribute.AttributePath ?? string.Empty).Trim(), + "stVal", + StringComparison.OrdinalIgnoreCase) && + attribute.TypeConfidence == LiveIedDiscoveryConfidenceLevel.Exact && + !string.Equals( + (attribute.TypeSource ?? string.Empty).Trim(), + "SCL", + StringComparison.OrdinalIgnoreCase)); + + if (exactLiveStVal is null) + return declaredCdc; + + var typed = CdcInferenceEngine.Infer( + logicalNodeClass, + dataObject.Name, + dataObject.Attributes); + + if (!CdcInferenceEngine.IsKnownCdc(typed.Cdc) || + typed.Confidence < 0.95 || + string.Equals(typed.Cdc, declaredCdc, StringComparison.OrdinalIgnoreCase)) + { + return declaredCdc; + } + + context.Warnings.Add(new LiveIedSclExportWarning + { + Code = "ExactLiveTypeCdcAuthority", + Reference = dataObject.Reference, + Message = + $"Exact live stVal TypeSpecification corrected exported CDC from " + + $"'{declaredCdc}' to '{typed.Cdc}'." + }); + + return typed.Cdc; + } + private static XElement BuildSubDataObjectType( TypeTreeNode node, string doTypeId, diff --git a/tests/AR.Iec61850.Tests/Scl/LiveIedSclSemanticAuthorityTests.cs b/tests/AR.Iec61850.Tests/Scl/LiveIedSclSemanticAuthorityTests.cs new file mode 100644 index 00000000..da60c229 --- /dev/null +++ b/tests/AR.Iec61850.Tests/Scl/LiveIedSclSemanticAuthorityTests.cs @@ -0,0 +1,207 @@ +using System.Xml.Linq; +using AR.Iec61850.Discovery; +using AR.Iec61850.Scl.Engineering; +using AR.Iec61850.Scl.Export; + +namespace AR.Iec61850.Tests.Scl; + +public sealed class LiveIedSclSemanticAuthorityTests +{ + [Fact] + public void ExactLiveIntegerStatus_RepairsStaleBooleanCdc_AndRoundTripsAsInsInt32() + { + var model = new LiveIedModelDiscoveryDocument + { + IedName = "IED", + AccessPointName = "AP1", + LogicalDevices = + [ + new LiveIedLogicalDeviceModel + { + MmsDomain = "IEDADD", + Inst = "ADD", + LogicalNodes = + [ + new LiveIedLogicalNodeModel + { + Name = "GGIO1", + LnClass = "GGIO", + LnInst = "1", + ProposedLnTypeId = "LN_GGIO_GGIO1", + DataObjects = + [ + new LiveIedDataObjectModel + { + Reference = "IEDADD/GGIO1.CBClsCounter", + Name = "CBClsCounter", + ProposedDoTypeId = "DO_SPS_GGIO_CBClsCounter", + // Deliberately stale to prove the export boundary + // cannot serialize an exact integer status as SPS. + InferredCdc = "SPS", + CdcConfidence = 0.38, + ConfidenceLevel = LiveIedDiscoveryConfidenceLevel.Medium, + Attributes = + [ + Attribute("CBClsCounter", "stVal", "INT32", "integer"), + Attribute("CBClsCounter", "q", "Quality", "bit-string"), + Attribute("CBClsCounter", "t", "Timestamp", "utc-time") + ] + } + ] + } + ] + } + ] + }; + + var document = LiveIedSclExporter.BuildDocument( + model, + new LiveIedSclExportOptions + { + Profile = "full-model" + }); + + AssertExportedCounterIsInsInt32(document); + + var reopened = SclLiveModelProjectionBuilder.Build( + document, + "generated.iid"); + var counter = Assert.Single( + reopened.LogicalDevices + .SelectMany(device => device.LogicalNodes) + .SelectMany(node => node.DataObjects), + dataObject => dataObject.Name == "CBClsCounter"); + + Assert.Equal("INS", counter.InferredCdc); + var stVal = Assert.Single( + counter.Attributes, + attribute => attribute.AttributePath == "stVal"); + Assert.Equal("INT32", stVal.SclBType); + Assert.Equal( + LiveIedDiscoveryConfidenceLevel.Exact, + stVal.TypeConfidence); + } + + [Fact] + public void ExactLiveBooleanStatus_RemainsSpsBooleanAcrossExportAndReopen() + { + var model = BuildStatusModel( + dataObjectName: "BinaryStatus", + declaredCdc: "SPS", + stValBType: "BOOLEAN", + stValMmsType: "boolean"); + + var document = LiveIedSclExporter.BuildDocument( + model, + new LiveIedSclExportOptions + { + Profile = "full-model" + }); + + var reopened = SclLiveModelProjectionBuilder.Build( + document, + "generated.iid"); + var status = Assert.Single( + reopened.LogicalDevices + .SelectMany(device => device.LogicalNodes) + .SelectMany(node => node.DataObjects), + dataObject => dataObject.Name == "BinaryStatus"); + + Assert.Equal("SPS", status.InferredCdc); + Assert.Equal( + "BOOLEAN", + Assert.Single( + status.Attributes, + attribute => attribute.AttributePath == "stVal").SclBType); + } + + private static LiveIedModelDiscoveryDocument BuildStatusModel( + string dataObjectName, + string declaredCdc, + string stValBType, + string stValMmsType) + => new() + { + IedName = "IED", + AccessPointName = "AP1", + LogicalDevices = + [ + new LiveIedLogicalDeviceModel + { + MmsDomain = "IEDADD", + Inst = "ADD", + LogicalNodes = + [ + new LiveIedLogicalNodeModel + { + Name = "GGIO1", + LnClass = "GGIO", + LnInst = "1", + ProposedLnTypeId = "LN_GGIO_GGIO1", + DataObjects = + [ + new LiveIedDataObjectModel + { + Reference = "IEDADD/GGIO1." + dataObjectName, + Name = dataObjectName, + ProposedDoTypeId = "DO_" + declaredCdc + "_GGIO_" + dataObjectName, + InferredCdc = declaredCdc, + CdcConfidence = 0.98, + ConfidenceLevel = LiveIedDiscoveryConfidenceLevel.High, + Attributes = + [ + Attribute(dataObjectName, "stVal", stValBType, stValMmsType), + Attribute(dataObjectName, "q", "Quality", "bit-string"), + Attribute(dataObjectName, "t", "Timestamp", "utc-time") + ] + } + ] + } + ] + } + ] + }; + + private static LiveIedDataAttributeModel Attribute( + string dataObjectName, + string path, + string sclBType, + string mmsType) + => new() + { + ObjectReference = "IEDADD/GGIO1." + dataObjectName + "." + path, + AttributePath = path, + FunctionalConstraint = "ST", + MmsReference = "IEDADD/GGIO1$ST$" + dataObjectName + "$" + path, + MmsItemName = "GGIO1$ST$" + dataObjectName + "$" + path, + Source = "LiveMmsTypeSpecification", + SclBType = sclBType, + MmsType = mmsType, + TypeDiscoveryStatus = "Exact", + TypeSource = "LiveMmsTypeSpecification", + TypeConfidence = LiveIedDiscoveryConfidenceLevel.Exact, + FunctionalConstraintConfidence = LiveIedDiscoveryConfidenceLevel.Exact + }; + + private static void AssertExportedCounterIsInsInt32(XDocument document) + { + var ns = document.Root!.Name.Namespace; + var lNodeType = Assert.Single( + document.Descendants(ns + "LNodeType"), + element => (string?)element.Attribute("lnClass") == "GGIO"); + var dataObject = Assert.Single( + lNodeType.Elements(ns + "DO"), + element => (string?)element.Attribute("name") == "CBClsCounter"); + var doTypeId = (string?)dataObject.Attribute("type") ?? string.Empty; + var doType = Assert.Single( + document.Descendants(ns + "DOType"), + element => (string?)element.Attribute("id") == doTypeId); + + Assert.Equal("INS", (string?)doType.Attribute("cdc")); + + var stVal = Assert.Single( + doType.Elements(ns + "DA"), + element => (string?)element.Attribute("name") == "stVal"); + Assert.Equal("INT32", (string?)stVal.Attribute("bType")); + } +}