From 1135c92ea37ba6a5f83fd574858c3919c9632050 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 20 Sep 2026 10:36:34 +0700 Subject: [PATCH 1/5] discovery: let exact primary type refine generic status CDC --- .../Discovery/CdcInferenceEngine.cs | 68 +++++++++++++++++++ 1 file changed, 68 insertions(+) diff --git a/src/AR.Iec61850/Discovery/CdcInferenceEngine.cs b/src/AR.Iec61850/Discovery/CdcInferenceEngine.cs index f580a835..f84e3b05 100644 --- a/src/AR.Iec61850/Discovery/CdcInferenceEngine.cs +++ b/src/AR.Iec61850/Discovery/CdcInferenceEngine.cs @@ -25,6 +25,62 @@ public static class CdcInferenceEngine public static bool IsKnownCdc(string cdc) => !string.IsNullOrWhiteSpace(cdc) && KnownCdcValues.Contains(cdc.Trim()); + /// + /// Refines only low/medium-confidence generic status inference when the MMS + /// TypeSpecification provides an exact primitive stVal type. Exact wire type is + /// stronger evidence than the generic ST/stVal/q/t shape; object names are never + /// used as a substitute for missing type evidence. + /// + public static CdcInferenceResult RefineWithExactPrimaryType( + CdcInferenceResult inferred, + IReadOnlyCollection attributes) + { + ArgumentNullException.ThrowIfNull(inferred); + ArgumentNullException.ThrowIfNull(attributes); + + if (!string.Equals(inferred.Cdc, "SPS", StringComparison.OrdinalIgnoreCase) || + inferred.Confidence >= 0.85) + { + return inferred; + } + + var exactStatusValues = attributes + .Where(attribute => + attribute.TypeConfidence == LiveIedDiscoveryConfidenceLevel.Exact && + string.Equals( + (attribute.AttributePath ?? string.Empty).Trim().Split('.').LastOrDefault(), + "stVal", + StringComparison.OrdinalIgnoreCase)) + .ToArray(); + + if (exactStatusValues.Length != 1) + return inferred; + + var primary = exactStatusValues[0]; + var bType = (primary.SclBType ?? string.Empty).Trim().ToUpperInvariant(); + var mmsType = (primary.MmsType ?? string.Empty).Trim().ToUpperInvariant(); + + if (IsExactSignedIntegerType(bType, mmsType)) + { + return Result( + "INS", + 0.96, + inferred.Evidence.Append( + $"exact MMS TypeSpecification stVal={FirstNonEmpty(primary.SclBType, primary.MmsType)} overrides generic SPS shape")); + } + + if (IsExactBooleanType(bType, mmsType)) + { + return Result( + "SPS", + Math.Max(inferred.Confidence, 0.96), + inferred.Evidence.Append( + $"exact MMS TypeSpecification stVal={FirstNonEmpty(primary.SclBType, primary.MmsType)} confirms SPS")); + } + + return inferred; + } + public static CdcInferenceResult Infer( string logicalNodeClass, string dataObjectName, @@ -133,6 +189,18 @@ public static CdcInferenceResult Infer( return Result(string.Empty, 0.0, evidence.Append("CDC cannot be inferred from current online discovery only")); } + private static bool IsExactSignedIntegerType(string bType, string mmsType) + => bType is "INT8" or "INT16" or "INT24" or "INT32" or "INT64" or "INTEGER" || + mmsType.Contains("INTEGER", StringComparison.Ordinal) || + mmsType.Contains("SIGNED", StringComparison.Ordinal); + + private static bool IsExactBooleanType(string bType, string mmsType) + => bType == "BOOLEAN" || + mmsType.Contains("BOOLEAN", StringComparison.Ordinal); + + 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 fc235f3daa5d505fa63fa1a965eb52301cb6ec8b Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 20 Sep 2026 10:36:44 +0700 Subject: [PATCH 2/5] discovery: refine generic CDC from exact stVal type --- src/AR.Iec61850/Discovery/LiveIedModelDiscoveryBuilder.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/AR.Iec61850/Discovery/LiveIedModelDiscoveryBuilder.cs b/src/AR.Iec61850/Discovery/LiveIedModelDiscoveryBuilder.cs index 21025d14..744cbe56 100644 --- a/src/AR.Iec61850/Discovery/LiveIedModelDiscoveryBuilder.cs +++ b/src/AR.Iec61850/Discovery/LiveIedModelDiscoveryBuilder.cs @@ -215,7 +215,8 @@ private static IEnumerable BuildDataObjects( .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 inferredCdc = CdcInferenceEngine.Infer(parsedLn.SclLnClass, group.Key, attrPaths, fcs); + var cdc = CdcInferenceEngine.RefineWithExactPrimaryType(inferredCdc, attributes); var reference = $"{ln.Domain}/{ln.Name}.{group.Key}"; yield return new LiveIedDataObjectModel { From 995e4a7994058d5ec17099594df0e2c785ead394 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 20 Sep 2026 10:37:07 +0700 Subject: [PATCH 3/5] scl: preserve exact MMS primitive types over CDC fallback --- .../Scl/Export/LiveIedSclExporter.cs | 33 ++++++++++++++++--- 1 file changed, 29 insertions(+), 4 deletions(-) diff --git a/src/AR.Iec61850/Scl/Export/LiveIedSclExporter.cs b/src/AR.Iec61850/Scl/Export/LiveIedSclExporter.cs index ae583318..0be1e0b3 100644 --- a/src/AR.Iec61850/Scl/Export/LiveIedSclExporter.cs +++ b/src/AR.Iec61850/Scl/Export/LiveIedSclExporter.cs @@ -877,7 +877,7 @@ private static XElement BuildDaElement(TypeTreeNode node, string ownerTypeId, Li new XAttribute("type", enumTypeId)); } - var bType = NormalizeBType(node.BType, node.Name, node.Path, cdc); + var bType = NormalizeBType(node.BType, node.Name, node.Path, cdc, node.TypeConfidence); return new XElement( Scl + (isRootDa ? "DA" : "BDA"), new XAttribute("name", SafeXmlName(node.Name)), @@ -1198,7 +1198,12 @@ private static bool IsControlBlockDataObject(LiveIedDataObjectModel dataObject) string.Equals(attribute.FunctionalConstraint, "US", StringComparison.OrdinalIgnoreCase) || string.Equals(attribute.FunctionalConstraint, "LG", StringComparison.OrdinalIgnoreCase)); - private static string NormalizeBType(string bType, string name, string path, string cdc) + private static string NormalizeBType( + string bType, + string name, + string path, + string cdc, + LiveIedDiscoveryConfidenceLevel typeConfidence) { if (string.Equals(name, "q", StringComparison.OrdinalIgnoreCase)) return "Quality"; @@ -1207,15 +1212,32 @@ private static string NormalizeBType(string bType, string name, string path, str if (string.Equals(name, "T", StringComparison.Ordinal)) return "Timestamp"; + var normalizedBType = (bType ?? string.Empty).Trim(); + var exactPrimitive = + typeConfidence == LiveIedDiscoveryConfidenceLevel.Exact && + !string.IsNullOrWhiteSpace(normalizedBType) && + !normalizedBType.Equals("Unknown", StringComparison.OrdinalIgnoreCase) && + !normalizedBType.Equals("Struct", StringComparison.OrdinalIgnoreCase); + + // Exact MMS TypeSpecification is protocol evidence. Never overwrite it merely + // because a generic CDC shape inferred a different primitive type. + if (exactPrimitive) + return normalizedBType; + var normalizedCdc = cdc.Trim().ToUpperInvariant(); var normalizedPath = path.Trim(); var inferred = InferBTypeFromCdc(normalizedCdc, name, normalizedPath); if (!string.IsNullOrWhiteSpace(inferred)) return inferred; - if (string.IsNullOrWhiteSpace(bType) || string.Equals(bType, "Unknown", StringComparison.OrdinalIgnoreCase) || string.Equals(bType, "Struct", StringComparison.OrdinalIgnoreCase)) + if (string.IsNullOrWhiteSpace(normalizedBType) || + normalizedBType.Equals("Unknown", StringComparison.OrdinalIgnoreCase) || + normalizedBType.Equals("Struct", StringComparison.OrdinalIgnoreCase)) + { return "VisString255"; - return bType.Trim(); + } + + return normalizedBType; } private static string InferBTypeFromCdc(string cdc, string name, string path) @@ -1428,6 +1450,8 @@ private TypeTreeNode(string name, string path) public string Path { get; } public string Fc { get; private set; } = string.Empty; public string BType { get; private set; } = string.Empty; + public LiveIedDiscoveryConfidenceLevel TypeConfidence { get; private set; } = + LiveIedDiscoveryConfidenceLevel.Unknown; public IReadOnlyList Children => _orderedChildren; public string EffectiveFunctionalConstraint @@ -1491,6 +1515,7 @@ private void Add(LiveIedDataAttributeModel attribute) current.Fc = attribute.FunctionalConstraint; current.BType = attribute.SclBType; + current.TypeConfidence = attribute.TypeConfidence; } } } From a56f493cc3ad997b0a82cc1046c307a1c1fec9c6 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 20 Sep 2026 10:37:55 +0700 Subject: [PATCH 4/5] tests: lock exact integer status SCL semantics --- .../Scl/LiveIedSclExporterTests.cs | 103 ++++++++++++++++++ 1 file changed, 103 insertions(+) diff --git a/tests/AR.Iec61850.Tests/Scl/LiveIedSclExporterTests.cs b/tests/AR.Iec61850.Tests/Scl/LiveIedSclExporterTests.cs index 2cb0de25..25bdf4b8 100644 --- a/tests/AR.Iec61850.Tests/Scl/LiveIedSclExporterTests.cs +++ b/tests/AR.Iec61850.Tests/Scl/LiveIedSclExporterTests.cs @@ -102,6 +102,109 @@ public void Exporter_Preserves_Mms_TypeSpecification_Declaration_Order_For_Statu .ToArray()); } + [Fact] + public void Exact_Integer_StVal_Refines_Generic_Status_To_Ins_And_Preserves_Int32() + { + var discovery = new MmsDiscoveryResult + { + IedDirectory = new MmsIedModelDirectory( + [ + new MmsFcResolvedPoint + { + Domain = "IEDADD", + LogicalNode = "GGIO1", + FunctionalConstraint = "ST", + DataObjectPath = "UserStatus.stVal", + MmsItemName = "GGIO1$ST$UserStatus$stVal" + }, + new MmsFcResolvedPoint + { + Domain = "IEDADD", + LogicalNode = "GGIO1", + FunctionalConstraint = "ST", + DataObjectPath = "UserStatus.q", + MmsItemName = "GGIO1$ST$UserStatus$q" + }, + new MmsFcResolvedPoint + { + Domain = "IEDADD", + LogicalNode = "GGIO1", + FunctionalConstraint = "ST", + DataObjectPath = "UserStatus.t", + MmsItemName = "GGIO1$ST$UserStatus$t" + } + ]) + }; + var typeResult = new MmsVariableAccessAttributesResult + { + IsSuccess = true, + Reference = new MmsObjectReference("IEDADD", "GGIO1", string.Empty), + TypeSpecification = new MmsTypeSpecificationNode + { + MmsType = "structure", + SclBType = "Struct", + Children = + [ + new MmsTypeSpecificationNode + { + Name = "ST", + MmsType = "structure", + SclBType = "Struct", + Children = + [ + new MmsTypeSpecificationNode + { + Name = "UserStatus", + MmsType = "structure", + SclBType = "Struct", + Children = + [ + new MmsTypeSpecificationNode { Name = "stVal", MmsType = "integer", SclBType = "INT32" }, + new MmsTypeSpecificationNode { Name = "q", MmsType = "bit-string", SclBType = "Quality" }, + new MmsTypeSpecificationNode { Name = "t", MmsType = "utc-time", SclBType = "Timestamp" } + ] + } + ] + } + ] + } + }; + + var model = LiveIedModelDiscoveryBuilder.Build( + discovery, + new LiveIedModelDiscoveryBuildOptions { IedName = "IED" }, + variableTypeAttributes: [typeResult]); + + var dataObject = Assert.Single( + model.LogicalDevices + .SelectMany(device => device.LogicalNodes) + .SelectMany(node => node.DataObjects), + item => item.Name == "UserStatus"); + Assert.Equal("INS", dataObject.InferredCdc); + Assert.Equal(LiveIedDiscoveryConfidenceLevel.Exact, + Assert.Single(dataObject.Attributes, attribute => attribute.AttributePath == "stVal").TypeConfidence); + + var document = LiveIedSclExporter.BuildDocument( + model, + new LiveIedSclExportOptions { Profile = "full-model" }); + var ns = document.Root!.Name.Namespace; + var lNodeType = Assert.Single( + document.Descendants(ns + "LNodeType"), + element => (string?)element.Attribute("lnClass") == "GGIO"); + var doRef = Assert.Single( + lNodeType.Elements(ns + "DO"), + element => (string?)element.Attribute("name") == "UserStatus"); + var doType = Assert.Single( + document.Descendants(ns + "DOType"), + element => (string?)element.Attribute("id") == (string?)doRef.Attribute("type")); + + 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")); + } + [Fact] public void Exporter_Preserves_CaseDistinct_Ltrk_Cts_Members_From_Root_TypeSpecification() { From 5d8c9959f638290a7aaf45988ef45ed823639d2a Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 20 Sep 2026 10:40:24 +0700 Subject: [PATCH 5/5] docs: keep standard model registry wording public-neutral --- 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..4e92e272 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 field-reference SCL evidence 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 field-reference 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"),